mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: let desktop users opt in to usage info and crash reports, each with its own switch
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { DesktopCrashFile, DesktopCrashSendResult, DesktopPrivacyState } from '@/lib/desktop/types';
|
||||
import { DesktopTelemetrySettings } from '../desktop-telemetry-settings';
|
||||
|
||||
// The desktop app's two switches. A browser sees nothing. The shell decides and asks; this
|
||||
// page only shows what it says, and sends a crash file only when the button is pressed.
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast }));
|
||||
vi.mock('next-intl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('next-intl')>()),
|
||||
useTranslations: () => (key: string) => key,
|
||||
useFormatter: () => ({ dateTime: (date: Date) => date.toISOString() }),
|
||||
}));
|
||||
vi.mock('../settings-section', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../settings-section')>()),
|
||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
const OFF: DesktopPrivacyState = {
|
||||
telemetry: { on: false, forcedOff: false },
|
||||
crashReports: { on: false, forcedOff: false, uploadUrl: '' },
|
||||
};
|
||||
const CRASH_ON: DesktopPrivacyState = { ...OFF, crashReports: { on: true, forcedOff: false, uploadUrl: '' } };
|
||||
const FILE: DesktopCrashFile = { id: 'a1', createdAt: Date.parse('2026-11-03T10:00:00Z'), bytes: 2048 };
|
||||
|
||||
function bridge(state: DesktopPrivacyState, crashes: DesktopCrashFile[] = [], mode: 'standalone' | 'instance' = 'standalone') {
|
||||
const privacy = {
|
||||
get: vi.fn(async () => state),
|
||||
setTelemetry: vi.fn(async (on: boolean) => ({ ...state, telemetry: { ...state.telemetry, on } })),
|
||||
setCrashReports: vi.fn(async (on: boolean) => ({ ...state, crashReports: { ...state.crashReports, on } })),
|
||||
setCrashUploadUrl: vi.fn(async (_url: string) => ({ ok: true, state })),
|
||||
listCrashes: vi.fn(async () => crashes),
|
||||
sendCrash: vi.fn(async (_id: string): Promise<DesktopCrashSendResult> => ({ ok: true })),
|
||||
deleteCrashes: vi.fn(async () => {}),
|
||||
onChange: vi.fn(() => () => {}),
|
||||
};
|
||||
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode, privacy };
|
||||
return privacy;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
toast.success.mockClear();
|
||||
toast.error.mockClear();
|
||||
});
|
||||
|
||||
const checked = (id: string) => screen.getByTestId(id).getAttribute('aria-checked');
|
||||
|
||||
describe('usage info and crash reports', () => {
|
||||
it('shows nothing in a browser', async () => {
|
||||
const { container } = render(<DesktopTelemetrySettings />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('shows nothing under a shell without the bridge member', async () => {
|
||||
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' };
|
||||
const { container } = render(<DesktopTelemetrySettings />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('starts with both off, and says what a crash file can hold', async () => {
|
||||
bridge(OFF);
|
||||
render(<DesktopTelemetrySettings />);
|
||||
await screen.findByTestId('desktop-telemetry-toggle');
|
||||
expect(checked('desktop-telemetry-toggle')).toBe('false');
|
||||
expect(checked('desktop-crash-toggle')).toBe('false');
|
||||
expect(screen.getByText('crash_memory')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('asks the shell and shows its answer', async () => {
|
||||
const privacy = bridge(OFF);
|
||||
render(<DesktopTelemetrySettings />);
|
||||
fireEvent.click(await screen.findByTestId('desktop-telemetry-toggle'));
|
||||
await waitFor(() => expect(privacy.setTelemetry).toHaveBeenCalledWith(true));
|
||||
await waitFor(() => expect(checked('desktop-telemetry-toggle')).toBe('true'));
|
||||
expect(privacy.setCrashReports).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a switch still off when the user said no in the shell\'s dialog', async () => {
|
||||
const privacy = bridge(OFF);
|
||||
privacy.setCrashReports.mockResolvedValue(OFF);
|
||||
render(<DesktopTelemetrySettings />);
|
||||
fireEvent.click(await screen.findByTestId('desktop-crash-toggle'));
|
||||
await waitFor(() => expect(privacy.setCrashReports).toHaveBeenCalledWith(true));
|
||||
expect(checked('desktop-crash-toggle')).toBe('false');
|
||||
});
|
||||
|
||||
it('locks both when a policy forces them off, and says who did', async () => {
|
||||
const privacy = bridge({
|
||||
telemetry: { on: false, forcedOff: true },
|
||||
crashReports: { on: false, forcedOff: true, uploadUrl: '' },
|
||||
});
|
||||
render(<DesktopTelemetrySettings />);
|
||||
const toggle = await screen.findByTestId('desktop-telemetry-toggle');
|
||||
expect(toggle.hasAttribute('disabled')).toBe(true);
|
||||
expect(screen.getByTestId('desktop-crash-toggle').hasAttribute('disabled')).toBe(true);
|
||||
expect(screen.getAllByText('forced_off')).toHaveLength(2);
|
||||
fireEvent.click(toggle);
|
||||
expect(privacy.setTelemetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds the note about the instance\'s own admin only on an instance', async () => {
|
||||
bridge(OFF, [], 'instance');
|
||||
render(<DesktopTelemetrySettings />);
|
||||
await screen.findByTestId('desktop-telemetry-toggle');
|
||||
expect(screen.getByText(/instance_note/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('crash files', () => {
|
||||
it('sends only when the button is pressed', async () => {
|
||||
const privacy = bridge(CRASH_ON, [FILE]);
|
||||
render(<DesktopTelemetrySettings />);
|
||||
const send = await screen.findByTestId('desktop-crash-send');
|
||||
expect(privacy.sendCrash).not.toHaveBeenCalled();
|
||||
fireEvent.click(send);
|
||||
await waitFor(() => expect(privacy.sendCrash).toHaveBeenCalledWith('a1'));
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('sent'));
|
||||
});
|
||||
|
||||
it('says so when no address is set', async () => {
|
||||
const privacy = bridge(CRASH_ON, [FILE]);
|
||||
privacy.sendCrash.mockResolvedValue({ ok: false, reason: 'no-url' });
|
||||
render(<DesktopTelemetrySettings />);
|
||||
fireEvent.click(await screen.findByTestId('desktop-crash-send'));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('send_no_url'));
|
||||
});
|
||||
|
||||
it('says nothing when the user said no in the dialog', async () => {
|
||||
const privacy = bridge(CRASH_ON, [FILE]);
|
||||
privacy.sendCrash.mockResolvedValue({ ok: false, reason: 'cancelled' });
|
||||
render(<DesktopTelemetrySettings />);
|
||||
fireEvent.click(await screen.findByTestId('desktop-crash-send'));
|
||||
await waitFor(() => expect(privacy.sendCrash).toHaveBeenCalled());
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks for the address only with crash reports on, and shows a bad one\'s answer', async () => {
|
||||
const privacy = bridge(CRASH_ON);
|
||||
privacy.setCrashUploadUrl.mockResolvedValue({ ok: false, state: CRASH_ON });
|
||||
render(<DesktopTelemetrySettings />);
|
||||
const input = await screen.findByTestId('desktop-crash-url');
|
||||
fireEvent.change(input, { target: { value: 'ftp://nope' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'url_save' }));
|
||||
await waitFor(() => expect(privacy.setCrashUploadUrl).toHaveBeenCalledWith('ftp://nope'));
|
||||
await screen.findByText('url_invalid');
|
||||
});
|
||||
|
||||
it('hides the address with crash reports off', async () => {
|
||||
bridge(OFF);
|
||||
render(<DesktopTelemetrySettings />);
|
||||
await screen.findByTestId('desktop-telemetry-toggle');
|
||||
expect(screen.queryByTestId('desktop-crash-url')).toBeNull();
|
||||
});
|
||||
|
||||
it('deletes through the shell', async () => {
|
||||
const privacy = bridge(CRASH_ON, [FILE]);
|
||||
render(<DesktopTelemetrySettings />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'delete_all' }));
|
||||
await waitFor(() => expect(privacy.deleteCrashes).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import type { DesktopCrashFile, DesktopCrashSendResult, DesktopPrivacyState } from "@/lib/desktop/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
|
||||
|
||||
const SEND_ERROR: Record<Exclude<DesktopCrashSendResult, { ok: true }>["reason"], string | null> = {
|
||||
"no-consent": "send_no_consent",
|
||||
"no-url": "send_no_url",
|
||||
"not-found": "send_failed",
|
||||
failed: "send_failed",
|
||||
// The user said no in the dialog: nothing to tell them.
|
||||
cancelled: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Usage info and crash reports in the desktop app: each with its own switch, both off
|
||||
* until the user says yes, and only where there's an app. A browser never sees this, and
|
||||
* it renders nothing until the bridge has answered. The shell does the deciding, the
|
||||
* asking and the sending.
|
||||
*/
|
||||
export function DesktopTelemetrySettings() {
|
||||
const t = useTranslations("desktopTelemetry");
|
||||
const format = useFormatter();
|
||||
const bridge = getDesktopBridge();
|
||||
const privacy = bridge?.privacy;
|
||||
const [state, setState] = useState<DesktopPrivacyState | null>(null);
|
||||
const [crashes, setCrashes] = useState<DesktopCrashFile[]>([]);
|
||||
const [url, setUrl] = useState("");
|
||||
const [urlInvalid, setUrlInvalid] = useState(false);
|
||||
const [sending, setSending] = useState<string | null>(null);
|
||||
|
||||
const refreshCrashes = useCallback(() => {
|
||||
void privacy?.listCrashes().then(setCrashes, () => {});
|
||||
}, [privacy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!privacy) return;
|
||||
let stale = false;
|
||||
const apply = (next: DesktopPrivacyState) => {
|
||||
if (stale) return;
|
||||
setState(next);
|
||||
setUrl(next.crashReports.uploadUrl);
|
||||
};
|
||||
void privacy.get().then(apply, () => {});
|
||||
refreshCrashes();
|
||||
const stop = privacy.onChange((next) => {
|
||||
apply(next);
|
||||
refreshCrashes();
|
||||
});
|
||||
return () => {
|
||||
stale = true;
|
||||
stop();
|
||||
};
|
||||
}, [privacy, refreshCrashes]);
|
||||
|
||||
if (!privacy || !state) return null;
|
||||
const { telemetry, crashReports } = state;
|
||||
|
||||
const saveUrl = async () => {
|
||||
const result = await privacy.setCrashUploadUrl(url.trim());
|
||||
setUrlInvalid(!result.ok);
|
||||
if (result.ok) setState(result.state);
|
||||
};
|
||||
|
||||
const send = async (id: string) => {
|
||||
setSending(id);
|
||||
try {
|
||||
const result = await privacy.sendCrash(id);
|
||||
if (result.ok) toast.success(t("sent"));
|
||||
else if (SEND_ERROR[result.reason]) toast.error(t(SEND_ERROR[result.reason]!));
|
||||
} finally {
|
||||
setSending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const showCrashFiles = crashReports.on || crashes.length > 0;
|
||||
|
||||
return (
|
||||
<SettingsSection id="desktop-privacy" title={t("section_title")} description={t("section_description")}>
|
||||
<SettingItem
|
||||
label={t("telemetry_label")}
|
||||
description={telemetry.forcedOff ? t("forced_off") : t("telemetry_description")}
|
||||
locked={telemetry.forcedOff}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={telemetry.on}
|
||||
disabled={telemetry.forcedOff}
|
||||
testId="desktop-telemetry-toggle"
|
||||
onChange={(on) => void privacy.setTelemetry(on).then(setState)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label={t("crash_label")}
|
||||
description={crashReports.forcedOff ? t("forced_off") : t("crash_description")}
|
||||
locked={crashReports.forcedOff}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={crashReports.on}
|
||||
disabled={crashReports.forcedOff}
|
||||
testId="desktop-crash-toggle"
|
||||
onChange={(on) => void privacy.setCrashReports(on).then((next) => {
|
||||
setState(next);
|
||||
refreshCrashes();
|
||||
})}
|
||||
/>
|
||||
</SettingItem>
|
||||
{!crashReports.forcedOff && (
|
||||
<p className="py-2 text-xs text-muted-foreground">
|
||||
{t("crash_memory")}
|
||||
{bridge?.mode === "instance" && ` ${t("instance_note")}`}
|
||||
</p>
|
||||
)}
|
||||
{crashReports.on && (
|
||||
<SettingItem nested label={t("url_label")} description={urlInvalid ? t("url_invalid") : t("url_description")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="url"
|
||||
value={url}
|
||||
placeholder="https://crash.example.org/upload"
|
||||
data-testid="desktop-crash-url"
|
||||
onChange={(event) => {
|
||||
setUrl(event.target.value);
|
||||
setUrlInvalid(false);
|
||||
}}
|
||||
className="w-64"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => void saveUrl()}>{t("url_save")}</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
{showCrashFiles && (
|
||||
<SettingItem nested label={t("files_label")} description={t("files_description")}>
|
||||
{crashes.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void privacy.deleteCrashes().then(refreshCrashes)}
|
||||
>
|
||||
{t("delete_all")}
|
||||
</Button>
|
||||
)}
|
||||
</SettingItem>
|
||||
)}
|
||||
{showCrashFiles && crashes.length === 0 && (
|
||||
<p className="ms-4 ps-3 py-2 text-xs text-muted-foreground">{t("files_empty")}</p>
|
||||
)}
|
||||
{crashes.map((crash) => (
|
||||
<SettingItem
|
||||
key={crash.id}
|
||||
nested
|
||||
label={format.dateTime(new Date(crash.createdAt), { dateStyle: "medium", timeStyle: "short" })}
|
||||
description={t("file_size", { size: Math.max(1, Math.round(crash.bytes / 1024)) })}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={sending !== null}
|
||||
data-testid="desktop-crash-send"
|
||||
onClick={() => void send(crash.id)}
|
||||
>
|
||||
{t("send")}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
))}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { LanguageSettings } from './language-settings';
|
||||
import { DesktopKeystoreSettings } from './desktop-keystore-settings';
|
||||
import { DesktopTelemetrySettings } from './desktop-telemetry-settings';
|
||||
import { ProtocolHandlerSettings } from './protocol-handler-settings';
|
||||
import { useTour } from '@/components/tour/tour-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -27,6 +28,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
|
||||
|
||||
{!IS_LITE && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||
<DesktopKeystoreSettings />
|
||||
<DesktopTelemetrySettings />
|
||||
|
||||
<SettingsSection id="tour" title={tTour('settings_section')}>
|
||||
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
|
||||
|
||||
@@ -134,6 +134,9 @@ migrateLegacyAdminLayout()
|
||||
{ name: "telemetry-errors", start: instantErrors.startInstantErrors, stop: instantErrors.stopInstantErrors },
|
||||
{ name: "version-check", start: versionCheck.startScheduler, stop: versionCheck.stopScheduler },
|
||||
]);
|
||||
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === "1") {
|
||||
(await import("./lib/desktop/server-links")).installDesktopServerLinks();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn("Admin dashboard init skipped", { error: err instanceof Error ? err.message : String(err) });
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The desktop app's heartbeat and error counts, on a real socket: what counts is what
|
||||
// reaches the collector, so these listen on a port rather than stubbing fetch. Consent
|
||||
// comes from the shell, not from a file or the environment. The bodies go through the
|
||||
// collector's real validator.
|
||||
|
||||
const flag = vi.hoisted(() => ({ IS_DESKTOP_BUILD: true }));
|
||||
vi.mock('@/lib/desktop/flag', () => ({
|
||||
get IS_DESKTOP_BUILD() {
|
||||
return flag.IS_DESKTOP_BUILD;
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/admin/third-party-mode', () => ({ isNoThirdPartyTrafficOn: async () => false }));
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
getPluginRegistry: async () => ({ plugins: [] }),
|
||||
getThemeRegistry: async () => ({ themes: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
getPolicy: () => ({ features: { calendarEnabled: true } }),
|
||||
getAll: () => ({}),
|
||||
get: (_key: string, fallback: unknown) => fallback,
|
||||
},
|
||||
}));
|
||||
|
||||
import { buildPayload, loadState, saveState, sendOnce, startScheduler, stopScheduler } from '@/lib/telemetry';
|
||||
import { resetInstantErrorsForTests, sendInstantErrors } from '@/lib/telemetry/instant-errors';
|
||||
import { flushErrors, recordError, resetAggregateForTests } from '@/lib/error-reporting/aggregate';
|
||||
import { shouldCollectErrors } from '@/lib/error-reporting/settings';
|
||||
import { fetchTelemetryTarget } from '@/lib/telemetry/endpoint-guard';
|
||||
|
||||
const DASHBOARD = process.env.BULWARK_DASHBOARD_DIR ?? 'C:/Users/info/Downloads/jmap-webmail/repos/dashboard';
|
||||
const VALIDATE = path.join(DASHBOARD, 'telemetry-collector/src/validate.ts');
|
||||
const collector = existsSync(VALIDATE)
|
||||
? (await import(/* @vite-ignore */ pathToFileURL(VALIDATE).href) as {
|
||||
validate(input: unknown): { schema: string; build: string | null; platform: string; version: string } | null;
|
||||
validateErrorBatch(input: unknown): { errors: { version: string; frames: unknown[] }[] } | null;
|
||||
})
|
||||
: null;
|
||||
|
||||
interface Received {
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
let server: Server;
|
||||
let received: Received[] = [];
|
||||
let base = '';
|
||||
let dir = '';
|
||||
|
||||
const PRIVACY = Symbol.for('bulwark.desktop.privacy');
|
||||
const TRUSTED = Symbol.for('bulwark.desktop.trustedJmapOrigins');
|
||||
const holder = globalThis as unknown as Record<symbol, unknown>;
|
||||
|
||||
function shellSays(privacy: { telemetry: boolean; crashReports: boolean } | null): void {
|
||||
holder[PRIVACY] = privacy ? { ...privacy, appVersion: '0.3.0', build: 'desktop.standalone.beta' } : undefined;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req, res) => {
|
||||
let raw = '';
|
||||
req.on('data', (chunk) => { raw += chunk; });
|
||||
req.on('end', () => {
|
||||
received.push({ path: req.url ?? '', body: JSON.parse(raw) });
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end('{"ok":true}');
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
flag.IS_DESKTOP_BUILD = true;
|
||||
received = [];
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'bw-telemetry-desktop-'));
|
||||
vi.stubEnv('TELEMETRY_DATA_DIR', dir);
|
||||
vi.stubEnv('BULWARK_TELEMETRY_ALLOW_PRIVATE', '1');
|
||||
// What the shell's profile sets: the environment plays no part either way.
|
||||
vi.stubEnv('BULWARK_TELEMETRY', 'off');
|
||||
vi.stubEnv('BULWARK_TELEMETRY_ERRORS', '');
|
||||
shellSays(null);
|
||||
resetAggregateForTests();
|
||||
resetInstantErrorsForTests();
|
||||
await saveState({ consent: 'off', endpoint: `${base}/v1/heartbeat`, consentedAt: null, lastSentAt: null, nextScheduledAt: null });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopScheduler();
|
||||
delete holder[PRIVACY];
|
||||
delete holder[TRUSTED];
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('nothing leaves before consent', () => {
|
||||
it('sends no heartbeat when the shell has said nothing', async () => {
|
||||
expect((await sendOnce()).ok).toBe(false);
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends no heartbeat on a no from the shell, whatever the file or the environment say', async () => {
|
||||
shellSays({ telemetry: false, crashReports: false });
|
||||
vi.stubEnv('BULWARK_TELEMETRY', 'on');
|
||||
await saveState({ ...(await loadState()), consent: 'on' });
|
||||
expect((await sendOnce()).ok).toBe(false);
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends no error counts when the shell has said nothing', async () => {
|
||||
recordError({ name: 'TypeError', frames: [] }, 'server', '1.0.0');
|
||||
await flushErrors();
|
||||
await sendInstantErrors();
|
||||
expect(received).toEqual([]);
|
||||
expect(await shouldCollectErrors()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the heartbeat with consent', () => {
|
||||
it('goes out, and stops the moment consent goes', async () => {
|
||||
shellSays({ telemetry: true, crashReports: false });
|
||||
expect((await sendOnce()).ok).toBe(true);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].path).toBe('/v1/heartbeat');
|
||||
shellSays({ telemetry: false, crashReports: false });
|
||||
expect((await sendOnce()).ok).toBe(false);
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('says desktop, the app version, mode and channel, in schema 1', async () => {
|
||||
shellSays({ telemetry: true, crashReports: false });
|
||||
await sendOnce();
|
||||
expect(received[0].body).toMatchObject({ schema: '1', platform: 'desktop', version: '0.3.0', build: 'desktop.standalone.beta' });
|
||||
expect(received[0].body).not.toHaveProperty('errors');
|
||||
expect(received[0].body).not.toHaveProperty('mode');
|
||||
expect(received[0].body).not.toHaveProperty('channel');
|
||||
});
|
||||
|
||||
it.skipIf(!collector)('is what the real collector accepts, and it keeps the build', async () => {
|
||||
shellSays({ telemetry: true, crashReports: false });
|
||||
await sendOnce();
|
||||
const clean = collector!.validate(received[0].body)!;
|
||||
expect(clean).not.toBeNull();
|
||||
expect(clean.schema).toBe('1');
|
||||
expect(clean.build).toBe('desktop.standalone.beta');
|
||||
expect(clean.version).toBe('0.3.0');
|
||||
// `unknown` until the collector patch is deployed, then `desktop`.
|
||||
expect(['unknown', 'desktop']).toContain(clean.platform);
|
||||
// Schema 2 refuses any extra key, so this proves there is none.
|
||||
expect(collector!.validate({ ...received[0].body, schema: '2', errors: [] })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stays schema 1 with crash reports on too', async () => {
|
||||
shellSays({ telemetry: true, crashReports: true });
|
||||
expect((await buildPayload()).schema).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('quit within an hour of each start', () => {
|
||||
const nextIn = async () => new Date((await loadState()).nextScheduledAt!).getTime() - Date.now();
|
||||
|
||||
it('after a restart with a slot in the past, a heartbeat goes within a minute, not an hour', async () => {
|
||||
shellSays({ telemetry: true, crashReports: false });
|
||||
await saveState({ ...(await loadState()), nextScheduledAt: new Date(Date.now() - 3 * 86_400_000).toISOString() });
|
||||
await startScheduler();
|
||||
expect(await nextIn()).toBeLessThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
it('the first run with consent does not wait an hour either', async () => {
|
||||
shellSays({ telemetry: true, crashReports: false });
|
||||
await startScheduler();
|
||||
expect(await nextIn()).toBeLessThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
it('a slot still ahead stays where it is', async () => {
|
||||
shellSays({ telemetry: true, crashReports: false });
|
||||
const slot = new Date(Date.now() + 5 * 3_600_000).toISOString();
|
||||
await saveState({ ...(await loadState()), nextScheduledAt: slot });
|
||||
await startScheduler();
|
||||
expect(await nextIn()).toBeGreaterThan(4 * 3_600_000);
|
||||
});
|
||||
|
||||
it('a server build still waits the hour', async () => {
|
||||
flag.IS_DESKTOP_BUILD = false;
|
||||
vi.stubEnv('BULWARK_TELEMETRY', 'on');
|
||||
await saveState({ ...(await loadState()), nextScheduledAt: new Date(Date.now() - 86_400_000).toISOString() });
|
||||
await startScheduler();
|
||||
const wait = await nextIn();
|
||||
expect(wait).toBeGreaterThan(59 * 60_000);
|
||||
expect(wait).toBeLessThanOrEqual(3_600_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('crash reports have their own consent', () => {
|
||||
const mainFrame = { path: '/desktop/main.mjs', line: 4, column: 2 };
|
||||
const bundleFrame = { path: '/.next/server/chunks/1.js', line: 9, column: 1 };
|
||||
|
||||
it('collects and sends error counts with telemetry off, and no heartbeat', async () => {
|
||||
shellSays({ telemetry: false, crashReports: true });
|
||||
expect(await shouldCollectErrors()).toBe(true);
|
||||
recordError({ name: 'TypeError', frames: [bundleFrame] }, 'server', '1.20.0+desktop');
|
||||
await flushErrors();
|
||||
await sendInstantErrors();
|
||||
expect(received.map((r) => r.path)).toEqual(['/v1/errors']);
|
||||
expect((await sendOnce()).ok).toBe(false);
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('telemetry alone collects no errors', async () => {
|
||||
shellSays({ telemetry: true, crashReports: false });
|
||||
expect(await shouldCollectErrors()).toBe(false);
|
||||
recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop');
|
||||
await flushErrors();
|
||||
await sendInstantErrors();
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(!collector)('a batch with a main-process error is what the real collector accepts', async () => {
|
||||
shellSays({ telemetry: false, crashReports: true });
|
||||
recordError({ name: 'TypeError', frames: [mainFrame, bundleFrame] }, 'server', '1.20.0+desktop');
|
||||
await flushErrors();
|
||||
await sendInstantErrors();
|
||||
const clean = collector!.validateErrorBatch(received[0].body)!;
|
||||
expect(clean).not.toBeNull();
|
||||
expect(clean.errors[0].version).toBe('1.20.0+desktop');
|
||||
// The unpatched collector refuses a whole batch over a frame outside the bundles.
|
||||
expect(clean.errors[0].frames).toEqual([expect.objectContaining({ path: '/.next/server/chunks/1.js' })]);
|
||||
});
|
||||
|
||||
it('keeps main-process frames in what stays on this computer', async () => {
|
||||
shellSays({ telemetry: false, crashReports: true });
|
||||
recordError({ name: 'TypeError', frames: [mainFrame] }, 'server', '1.20.0+desktop');
|
||||
await flushErrors();
|
||||
const stored = JSON.parse(readFileSync(path.join(dir, 'errors.json'), 'utf8')) as Record<string, Record<string, { frames: { path: string }[] }>>;
|
||||
const entry = Object.values(Object.values(stored)[0])[0];
|
||||
expect(entry.frames[0].path).toBe('/desktop/main.mjs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the endpoint guard', () => {
|
||||
it('still refuses a private address when the shell trusts it as a mail server', async () => {
|
||||
vi.stubEnv('BULWARK_TELEMETRY_ALLOW_PRIVATE', '');
|
||||
holder[TRUSTED] = new Set([base, 'http://192.168.1.5']);
|
||||
await expect(fetchTelemetryTarget(`${base}/v1/heartbeat`, { method: 'POST', body: '{}' })).rejects.toThrow();
|
||||
await expect(fetchTelemetryTarget('http://192.168.1.5/v1/heartbeat', { method: 'POST', body: '{}' })).rejects.toThrow();
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it('a stored endpoint on a private address goes nowhere with consent', async () => {
|
||||
vi.stubEnv('BULWARK_TELEMETRY_ALLOW_PRIVATE', '');
|
||||
shellSays({ telemetry: true, crashReports: false });
|
||||
holder[TRUSTED] = new Set([base]);
|
||||
const result = await sendOnce();
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('endpoint blocked');
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a server build', () => {
|
||||
it('ignores what a shell would have said, so the admin\'s consent stands', async () => {
|
||||
flag.IS_DESKTOP_BUILD = false;
|
||||
shellSays({ telemetry: true, crashReports: true });
|
||||
vi.stubEnv('BULWARK_TELEMETRY', '');
|
||||
expect((await sendOnce()).ok).toBe(false);
|
||||
expect(received).toEqual([]);
|
||||
expect(await shouldCollectErrors()).toBe(false);
|
||||
await saveState({ ...(await loadState()), consent: 'on' });
|
||||
expect((await sendOnce()).ok).toBe(true);
|
||||
expect(received[0].body.platform).not.toBe('desktop');
|
||||
expect(received[0].body.build).not.toBe('desktop.standalone.beta');
|
||||
});
|
||||
});
|
||||
|
||||
// So a typo in the path can't quietly turn the checks above off here.
|
||||
it('finds the collector on this machine', () => {
|
||||
if (process.env.CI) return;
|
||||
writeFileSync(path.join(dir, 'probe'), '');
|
||||
expect(existsSync(VALIDATE)).toBe(true);
|
||||
});
|
||||
@@ -39,7 +39,7 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [
|
||||
host: 'telemetry.bulwarkmail.org',
|
||||
files: ['lib/telemetry/types.ts'],
|
||||
side: 'server',
|
||||
usedBy: 'Anonymous daily heartbeat, sent only after an admin turned telemetry on; with instant error counts on, also the current day\'s error counts as they change.',
|
||||
usedBy: 'Anonymous daily heartbeat, sent only after an admin, or the desktop app\'s user, turned telemetry on; with instant error counts on, also the current day\'s error counts as they change.',
|
||||
off: 'Off by default. BULWARK_TELEMETRY=off, or System > Usage stats (switch it off or clear the collector URL).',
|
||||
inMode: 'Never sent, whatever the telemetry setting says.',
|
||||
lite: false,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DESKTOP_CATCH_UP_MS,
|
||||
DESKTOP_HEARTBEAT_INTERVAL_MS,
|
||||
buildDesktopErrorBatch,
|
||||
buildDesktopPing,
|
||||
consentInForce,
|
||||
desktopBuildTag,
|
||||
desktopChannel,
|
||||
desktopOsFamily,
|
||||
forcedOffByPolicy,
|
||||
heartbeatDelayMs,
|
||||
} from '../desktop-telemetry';
|
||||
|
||||
const DASHBOARD = process.env.BULWARK_DASHBOARD_DIR ?? 'C:/Users/info/Downloads/jmap-webmail/repos/dashboard';
|
||||
const VALIDATE = path.join(DASHBOARD, 'telemetry-collector/src/validate.ts');
|
||||
const collector = existsSync(VALIDATE)
|
||||
? (await import(/* @vite-ignore */ pathToFileURL(VALIDATE).href) as {
|
||||
validate(input: unknown): Record<string, unknown> | null;
|
||||
validateErrorBatch(input: unknown): Record<string, unknown> | null;
|
||||
})
|
||||
: null;
|
||||
|
||||
const NOT_SET = { value: true, locked: false };
|
||||
const FORCED_OFF = { value: false, locked: true };
|
||||
const FORCED_ON = { value: true, locked: true };
|
||||
const RECOMMENDED_OFF = { value: false, locked: false };
|
||||
|
||||
describe('consent', () => {
|
||||
it.each([
|
||||
[undefined, NOT_SET, false],
|
||||
[null, NOT_SET, false],
|
||||
[false, NOT_SET, false],
|
||||
[true, NOT_SET, true],
|
||||
// A policy forcing it off beats the user's yes.
|
||||
[true, FORCED_OFF, false],
|
||||
// A policy allowing it doesn't answer for the user.
|
||||
[undefined, FORCED_ON, false],
|
||||
[true, FORCED_ON, true],
|
||||
// Only forced counts as forced.
|
||||
[true, RECOMMENDED_OFF, true],
|
||||
[undefined, RECOMMENDED_OFF, false],
|
||||
])('choice %s under %j is %s', (choice, policy, expected) => {
|
||||
expect(consentInForce(choice, policy)).toBe(expected);
|
||||
});
|
||||
|
||||
it('knows forced off from the rest', () => {
|
||||
expect(forcedOffByPolicy(FORCED_OFF)).toBe(true);
|
||||
expect(forcedOffByPolicy(RECOMMENDED_OFF)).toBe(false);
|
||||
expect(forcedOffByPolicy(FORCED_ON)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a heartbeat is due', () => {
|
||||
const now = Date.parse('2026-11-03T12:00:00Z');
|
||||
|
||||
it('soon, with none sent yet or the last one long ago', () => {
|
||||
expect(heartbeatDelayMs(undefined, now)).toBe(DESKTOP_CATCH_UP_MS);
|
||||
expect(heartbeatDelayMs(null, now)).toBe(DESKTOP_CATCH_UP_MS);
|
||||
expect(heartbeatDelayMs(now - 3 * DESKTOP_HEARTBEAT_INTERVAL_MS, now)).toBe(DESKTOP_CATCH_UP_MS);
|
||||
});
|
||||
|
||||
it('a day after the last, and not sooner', () => {
|
||||
expect(heartbeatDelayMs(now - 3_600_000, now)).toBe(DESKTOP_HEARTBEAT_INTERVAL_MS - 3_600_000);
|
||||
});
|
||||
|
||||
it('soon again if the clock went back', () => {
|
||||
expect(heartbeatDelayMs(now + 3_600_000, now)).toBe(DESKTOP_CATCH_UP_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the shell\'s own heartbeat', () => {
|
||||
const ping = () => buildDesktopPing({
|
||||
instanceId: 'f0c1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b',
|
||||
appVersion: '0.3.0-beta.1',
|
||||
nodeVersion: '22.11.0',
|
||||
osFamily: 'windows',
|
||||
mode: 'instance',
|
||||
channel: 'beta',
|
||||
now: Date.parse('2026-11-03T12:00:00Z'),
|
||||
});
|
||||
|
||||
it('claims nothing that is the instance\'s to report', () => {
|
||||
const body = ping();
|
||||
expect(Object.values(body.features)).toEqual([false, false, false, false, false, false]);
|
||||
expect(body.counts).toEqual({ accounts: '0', accounts_active_7d: '0', extensions_installed: 0, themes_installed: 0 });
|
||||
expect(body.stalwart_version).toBeNull();
|
||||
expect(body.instance_id).toBe('f0c1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b');
|
||||
});
|
||||
|
||||
it.skipIf(!collector)('is what the real collector accepts, as schema 1 and as schema 2', () => {
|
||||
const clean = collector!.validate(ping())!;
|
||||
expect(clean).toMatchObject({ schema: '1', build: 'desktop.instance.beta', version: '0.3.0-beta.1', os_family: 'windows', node_version: '22.11.0' });
|
||||
expect(['unknown', 'desktop']).toContain(clean.platform);
|
||||
// Any extra top-level field would make schema 2 refuse the whole heartbeat.
|
||||
expect(collector!.validate({ ...ping(), schema: '2', errors: [] })).not.toBeNull();
|
||||
});
|
||||
|
||||
it.skipIf(!collector)('main\'s error batch is what the real collector accepts', () => {
|
||||
const now = Date.now();
|
||||
const batch = buildDesktopErrorBatch('f0c1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b', now, [
|
||||
{ name: 'TypeError', fingerprint: '0123456789abcdef', version: '0.3.0+desktop', count: 7 },
|
||||
]);
|
||||
const clean = collector!.validateErrorBatch(batch)!;
|
||||
expect(clean).not.toBeNull();
|
||||
expect(clean.errors).toEqual([expect.objectContaining({ name: 'TypeError', count: '6-10', version: '0.3.0+desktop', days: 1, frames: [] })]);
|
||||
});
|
||||
|
||||
it('fits the collector\'s pattern for build on every mode and channel', () => {
|
||||
for (const mode of ['standalone', 'instance'] as const) {
|
||||
for (const channel of ['stable', 'beta'] as const) {
|
||||
expect(desktopBuildTag(mode, channel)).toMatch(/^[\w.+-]{1,64}$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('names the channel: forced, then the user, then the version', () => {
|
||||
expect(desktopChannel({ value: 'stable', locked: true }, 'beta', '1.0.0-beta.1')).toBe('stable');
|
||||
expect(desktopChannel({ value: 'stable', locked: false }, 'beta', '1.0.0')).toBe('beta');
|
||||
expect(desktopChannel({ value: 'stable', locked: false }, undefined, '1.0.0-beta.1')).toBe('beta');
|
||||
expect(desktopChannel({ value: 'stable', locked: false }, 'nonsense', '1.0.0')).toBe('stable');
|
||||
});
|
||||
|
||||
it('names the OS the way the collector does', () => {
|
||||
expect(desktopOsFamily('win32')).toBe('windows');
|
||||
expect(desktopOsFamily('darwin')).toBe('darwin');
|
||||
expect(desktopOsFamily('linux')).toBe('linux');
|
||||
expect(desktopOsFamily('freebsd')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('holds no host name, so only one place has the collector', () => {
|
||||
const source = readFileSync(path.join(__dirname, '..', 'desktop-telemetry.ts'), 'utf8');
|
||||
expect(source).not.toContain('bulwarkmail.org');
|
||||
});
|
||||
|
||||
it('the policy says which shell file acts on the two keys', async () => {
|
||||
const { POLICY_CONSUMERS } = await import('../policy-schema');
|
||||
expect(POLICY_CONSUMERS.TelemetryAllowed).toEqual({ repo: 'shell', file: 'src/main/features/privacy.ts' });
|
||||
expect(POLICY_CONSUMERS.CrashReportsAllowed).toEqual({ repo: 'shell', file: 'src/main/features/privacy.ts' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* What the desktop app's telemetry and crash reports agree on across the webmail and
|
||||
* the shell: when consent counts, what a heartbeat from the shell holds, and when one
|
||||
* is due.
|
||||
*
|
||||
* No DOM, no Node, no imports: the desktop repo keeps a byte-for-byte copy in
|
||||
* src/shared/desktop-telemetry.ts. Main can't import lib/telemetry, which needs the
|
||||
* store and node:dns, and in instance mode no server runs inside the app. It holds no
|
||||
* host name either: the shell has its own constant, and lib/telemetry/types.ts has this
|
||||
* side's.
|
||||
*
|
||||
* The collector decides the shape (repos/dashboard/telemetry-collector/src/validate.ts).
|
||||
* Schema 1 drops fields it doesn't know and schema 2 refuses the whole heartbeat over
|
||||
* one, so mode and channel travel in `build`, the one free field it keeps. It stores
|
||||
* `platform: 'desktop'` as `unknown` until it has been patched.
|
||||
*/
|
||||
|
||||
export type DesktopTelemetryMode = 'standalone' | 'instance';
|
||||
export type DesktopTelemetryChannel = 'stable' | 'beta';
|
||||
export type DesktopOsFamily = 'linux' | 'darwin' | 'windows' | 'unknown';
|
||||
|
||||
/** What main tells the embedded server, by message. Nothing in a page can write it. */
|
||||
export interface DesktopServerPrivacy {
|
||||
telemetry: boolean;
|
||||
crashReports: boolean;
|
||||
/** The desktop app's version. The webmail's own is in `NEXT_PUBLIC_APP_VERSION`. */
|
||||
appVersion: string;
|
||||
/** `desktop.<mode>.<channel>`. */
|
||||
build: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How soon after a start a heartbeat that's due goes out. The server's own hour is
|
||||
* right for a server. An app quit within an hour of every start would never send.
|
||||
*/
|
||||
export const DESKTOP_CATCH_UP_MS = 30_000;
|
||||
/** A failed send tries again this much later, not a day later. */
|
||||
export const DESKTOP_RETRY_MS = 60 * 60_000;
|
||||
export const DESKTOP_HEARTBEAT_INTERVAL_MS = 24 * 60 * 60_000;
|
||||
|
||||
/** The start of a main-process frame's path. Only the shell's own bundle gets it. */
|
||||
export const DESKTOP_FRAME_PREFIX = '/desktop/';
|
||||
/** On the version of everything a desktop app reports. The collector's version field allows it. */
|
||||
export const DESKTOP_VERSION_SUFFIX = '+desktop';
|
||||
|
||||
/** A policy that forces something off. A recommendation doesn't: the user's choice beats it. */
|
||||
export function forcedOffByPolicy(entry: { value: boolean; locked: boolean }): boolean {
|
||||
return entry.locked && entry.value === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether something opt-in is on. Only a yes counts: no answer is off, and a policy
|
||||
* allowing it still leaves the question to the user.
|
||||
*/
|
||||
export function consentInForce(choice: boolean | null | undefined, policy: { value: boolean; locked: boolean }): boolean {
|
||||
return choice === true && !forcedOffByPolicy(policy);
|
||||
}
|
||||
|
||||
export function desktopBuildTag(mode: DesktopTelemetryMode, channel: DesktopTelemetryChannel): string {
|
||||
return `desktop.${mode}.${channel}`;
|
||||
}
|
||||
|
||||
/** Forced beats the user's choice, which beats what the version says. */
|
||||
export function desktopChannel(
|
||||
policy: { value: DesktopTelemetryChannel; locked: boolean },
|
||||
choice: unknown,
|
||||
appVersion: string,
|
||||
): DesktopTelemetryChannel {
|
||||
if (policy.locked) return policy.value;
|
||||
if (choice === 'stable' || choice === 'beta') return choice;
|
||||
return appVersion.includes('-') ? 'beta' : 'stable';
|
||||
}
|
||||
|
||||
export function desktopOsFamily(platform: string): DesktopOsFamily {
|
||||
if (platform === 'win32') return 'windows';
|
||||
if (platform === 'darwin') return 'darwin';
|
||||
if (platform === 'linux') return 'linux';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** How long until the next heartbeat, given when the last one went out. */
|
||||
export function heartbeatDelayMs(lastSentAt: number | null | undefined, now: number): number {
|
||||
if (!lastSentAt || lastSentAt > now) return DESKTOP_CATCH_UP_MS;
|
||||
return Math.max(DESKTOP_CATCH_UP_MS, lastSentAt + DESKTOP_HEARTBEAT_INTERVAL_MS - now);
|
||||
}
|
||||
|
||||
export interface DesktopPingInput {
|
||||
/** The shell's own UUID, never an instance's. */
|
||||
instanceId: string;
|
||||
appVersion: string;
|
||||
nodeVersion: string;
|
||||
osFamily: DesktopOsFamily;
|
||||
mode: DesktopTelemetryMode;
|
||||
channel: DesktopTelemetryChannel;
|
||||
now: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's own heartbeat, for when no server runs inside the app. Features and counts
|
||||
* are the instance's to report, so these are all empty and nothing is counted twice.
|
||||
*/
|
||||
export function buildDesktopPing(input: DesktopPingInput) {
|
||||
return {
|
||||
schema: '1' as const,
|
||||
instance_id: input.instanceId,
|
||||
ts: new Date(input.now).toISOString(),
|
||||
version: input.appVersion,
|
||||
build: desktopBuildTag(input.mode, input.channel),
|
||||
platform: 'desktop',
|
||||
node_version: input.nodeVersion,
|
||||
os_family: input.osFamily,
|
||||
stalwart_version: null,
|
||||
features: { calendar: false, contacts: false, files: false, extensions: false, oauth_enabled: false, smime_enabled: false },
|
||||
counts: { accounts: '0', accounts_active_7d: '0', extensions_installed: 0, themes_installed: 0 },
|
||||
uptime_days: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function desktopErrorCountBucket(count: number): string {
|
||||
if (count <= 1) return '1';
|
||||
if (count <= 5) return '2-5';
|
||||
if (count <= 10) return '6-10';
|
||||
if (count <= 50) return '11-50';
|
||||
if (count <= 200) return '51-200';
|
||||
return '201+';
|
||||
}
|
||||
|
||||
export interface DesktopMainError {
|
||||
name: string;
|
||||
/** 16 hex digits. */
|
||||
fingerprint: string;
|
||||
version: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main's errors when no server runs inside the app: the collector's /v1/errors takes
|
||||
* this without a heartbeat, so it works with telemetry off. Name and count only. The
|
||||
* collector refuses a whole batch over a frame outside the webmail's bundles.
|
||||
*/
|
||||
export function buildDesktopErrorBatch(instanceId: string, now: number, errors: readonly DesktopMainError[]) {
|
||||
return {
|
||||
schema: '1' as const,
|
||||
instance_id: instanceId,
|
||||
day: new Date(now).toISOString().slice(0, 10),
|
||||
errors: errors.map((error) => ({
|
||||
name: error.name,
|
||||
fingerprint: error.fingerprint,
|
||||
frames: [],
|
||||
version: error.version,
|
||||
count: desktopErrorCountBucket(error.count),
|
||||
days: 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -823,8 +823,8 @@ export const POLICY_CONSUMERS: Record<PolicyKey, PolicyConsumer> = {
|
||||
UpdateChannel: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' },
|
||||
AppLockRequired: { pendingConsumer: 'step 7, app lock' },
|
||||
AppLockTimeoutMinutes: { pendingConsumer: 'step 7, app lock' },
|
||||
TelemetryAllowed: { pendingConsumer: 'step 7, telemetry consent' },
|
||||
CrashReportsAllowed: { pendingConsumer: 'step 7, telemetry consent' },
|
||||
TelemetryAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' },
|
||||
CrashReportsAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' },
|
||||
QuickComposeShortcut: { pendingConsumer: 'step 7, quick compose' },
|
||||
AutoSelectCertificateForUrls: { pendingConsumer: 'step 7, client certificates' },
|
||||
KeystoreAllowedOrigins: { pendingConsumer: 'feat/native-helper: setKeystoreAllowedOrigins in src/main/features/keystore.ts' },
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The two things main hands the embedded server while it runs. The server's host
|
||||
* (src/server/host.ts in the desktop repo) has no way into Next's bundles but globalThis,
|
||||
* so it calls what this leaves there. instrumentation.node.ts installs them, only in the
|
||||
* desktop build.
|
||||
*/
|
||||
|
||||
import { captureDesktopMainError, type DesktopMainErrorInput } from '@/lib/error-reporting/server';
|
||||
import { reschedule } from '@/lib/telemetry/sender';
|
||||
|
||||
const PRIVACY_CHANGED = Symbol.for('bulwark.desktop.privacyChanged');
|
||||
const MAIN_ERRORS = Symbol.for('bulwark.desktop.mainErrors');
|
||||
const MAIN_ERRORS_READY = Symbol.for('bulwark.desktop.mainErrorsReady');
|
||||
|
||||
type Holder = Record<symbol, unknown>;
|
||||
|
||||
function drainMainErrors(holder: Holder): void {
|
||||
const queue = holder[MAIN_ERRORS];
|
||||
if (!Array.isArray(queue)) return;
|
||||
for (const error of queue.splice(0)) void captureDesktopMainError(error as DesktopMainErrorInput);
|
||||
}
|
||||
|
||||
export function installDesktopServerLinks(): void {
|
||||
const holder = globalThis as unknown as Holder;
|
||||
// Turning telemetry on starts the heartbeat now, without a restart. Off needs no
|
||||
// more than the value: sendOnce reads it before every send.
|
||||
holder[PRIVACY_CHANGED] = () => {
|
||||
void reschedule();
|
||||
};
|
||||
// Errors from before this ran are waiting in the queue.
|
||||
holder[MAIN_ERRORS_READY] = () => drainMainErrors(holder);
|
||||
drainMainErrors(holder);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* The desktop user's telemetry and crash report consent, as the embedded server sees it.
|
||||
*
|
||||
* The Electron main process sends this at start and on every change (src/server/host.ts
|
||||
* in the desktop repo sets it on globalThis, which all of Next's bundles share, like the
|
||||
* list of trusted servers). Nothing in the renderer or the environment can write it.
|
||||
*
|
||||
* Only the desktop build reads it. No value means nobody has said yes: everything off.
|
||||
*/
|
||||
|
||||
import { IS_DESKTOP_BUILD } from './flag';
|
||||
import type { DesktopServerPrivacy } from './desktop-telemetry';
|
||||
|
||||
const PRIVACY_KEY = Symbol.for('bulwark.desktop.privacy');
|
||||
|
||||
export function desktopServerPrivacy(): DesktopServerPrivacy | null {
|
||||
if (!IS_DESKTOP_BUILD) return null;
|
||||
return (globalThis as unknown as Record<symbol, DesktopServerPrivacy | undefined>)[PRIVACY_KEY] ?? null;
|
||||
}
|
||||
@@ -192,6 +192,43 @@ export interface DesktopPolicyBridge {
|
||||
onChange: (listener: (snapshot: PolicySnapshot) => void) => () => void;
|
||||
}
|
||||
|
||||
export interface DesktopPrivacyState {
|
||||
/** `forcedOff`: a policy says no, so `on` is false and the user can't change it. */
|
||||
telemetry: { on: boolean; forcedOff: boolean };
|
||||
/** `uploadUrl` is where "Send crash report" goes. Empty: nowhere, and the action says so. */
|
||||
crashReports: { on: boolean; forcedOff: boolean; uploadUrl: string };
|
||||
}
|
||||
|
||||
export interface DesktopCrashFile {
|
||||
/** Opaque: the only values `sendCrash` accepts. */
|
||||
id: string;
|
||||
/** Milliseconds since 1970. */
|
||||
createdAt: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export type DesktopCrashSendResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: 'no-consent' | 'no-url' | 'not-found' | 'cancelled' | 'failed' };
|
||||
|
||||
/**
|
||||
* Telemetry and crash reports, each with its own consent, both off until the user says
|
||||
* yes. A same-origin plugin can reach this, so saying yes and sending a crash file both
|
||||
* ask in a native dialog that names where it goes.
|
||||
*/
|
||||
export interface DesktopPrivacyBridge {
|
||||
get: () => Promise<DesktopPrivacyState>;
|
||||
setTelemetry: (on: boolean) => Promise<DesktopPrivacyState>;
|
||||
setCrashReports: (on: boolean) => Promise<DesktopPrivacyState>;
|
||||
/** https, or http on this computer. Empty clears it. */
|
||||
setCrashUploadUrl: (url: string) => Promise<{ ok: boolean; state: DesktopPrivacyState }>;
|
||||
listCrashes: () => Promise<DesktopCrashFile[]>;
|
||||
sendCrash: (id: string) => Promise<DesktopCrashSendResult>;
|
||||
deleteCrashes: () => Promise<void>;
|
||||
/** Also fires when a policy changes. Returns the unsubscribe. */
|
||||
onChange: (listener: (state: DesktopPrivacyState) => void) => () => void;
|
||||
}
|
||||
|
||||
export interface BulwarkDesktopBridge {
|
||||
version: number;
|
||||
platform: 'win32' | 'darwin' | 'linux';
|
||||
@@ -206,6 +243,7 @@ export interface BulwarkDesktopBridge {
|
||||
auth?: DesktopAuthBridge;
|
||||
plugins?: DesktopPluginsBridge;
|
||||
keystore?: DesktopKeystoreBridge;
|
||||
privacy?: DesktopPrivacyBridge;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Main's errors on their way through the embedded server: handleServerReport counts them
|
||||
// and, with a DSN, sends them tagged. The webmail scrubs them, so a message off the disk or
|
||||
// out of a mail can't get through.
|
||||
|
||||
const flag = vi.hoisted(() => ({ IS_DESKTOP_BUILD: true, dsn: '' }));
|
||||
vi.mock('@/lib/desktop/flag', () => ({
|
||||
get IS_DESKTOP_BUILD() {
|
||||
return flag.IS_DESKTOP_BUILD;
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
get: (key: string, fallback: unknown) => (key === 'errorReportingDsn' ? flag.dsn : fallback),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/metrics', () => ({ metricsEnabled: () => false }));
|
||||
const consent = vi.hoisted(() => ({ value: 'instant' as 'instant' | 'off' }));
|
||||
vi.mock('@/lib/telemetry/state', () => ({
|
||||
effectiveErrorsConsent: async () => ({ consent: consent.value, source: 'env' }),
|
||||
telemetryDocs: () => ({ update: async () => {}, get: async () => null }),
|
||||
}));
|
||||
|
||||
import { readErrors, resetAggregateForTests, utcDay } from '../aggregate';
|
||||
import { captureDesktopMainError } from '../server';
|
||||
import { browserErrorSettings } from '../settings';
|
||||
|
||||
const MAIN = {
|
||||
name: 'TypeError',
|
||||
message: "ENOENT: open 'C:\\Users\\linus\\AppData\\Roaming\\Bulwark\\inbox.json' for ceo.private@acme-corp.example",
|
||||
stack: 'TypeError: secret\n at sendPing (/desktop/main.mjs:120:7)\n at file:///C:/Users/linus/elsewhere/other.js:1:1',
|
||||
mechanism: 'uncaughtexception',
|
||||
};
|
||||
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAggregateForTests();
|
||||
// The server's limiter sends one per fingerprint per hour.
|
||||
delete (globalThis as unknown as Record<symbol, unknown>)[Symbol.for('bulwark.error-reporting.server')];
|
||||
consent.value = 'instant';
|
||||
flag.IS_DESKTOP_BUILD = true;
|
||||
flag.dsn = '';
|
||||
fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function counted() {
|
||||
const day = (await readErrors())[utcDay()] ?? {};
|
||||
return Object.values(day);
|
||||
}
|
||||
|
||||
describe('an error from the main process', () => {
|
||||
it('is counted with its frames kept and the disk gone', async () => {
|
||||
await captureDesktopMainError(MAIN);
|
||||
const [entry] = await counted();
|
||||
expect(entry.name).toBe('TypeError');
|
||||
expect(entry.frames[0]).toMatchObject({ path: '/desktop/main.mjs', line: 120, fn: 'sendPing' });
|
||||
expect(entry.frames[1].path).toBe('[other]');
|
||||
expect(JSON.stringify(entry)).not.toContain('linus');
|
||||
expect(JSON.stringify(entry)).not.toContain('acme-corp');
|
||||
});
|
||||
|
||||
it('goes nowhere without consent', async () => {
|
||||
consent.value = 'off';
|
||||
flag.dsn = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
|
||||
await captureDesktopMainError(MAIN);
|
||||
expect(await counted()).toEqual([]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reaches a DSN tagged as desktop, in app, with nothing off the disk', async () => {
|
||||
flag.dsn = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
|
||||
await captureDesktopMainError(MAIN);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const body = String(fetchSpy.mock.calls[0][1].body);
|
||||
const event = JSON.parse(body.trim().split('\n')[2]) as {
|
||||
tags: Record<string, string>;
|
||||
exception: { values: { value: string; mechanism: { type: string }; stacktrace: { frames: { filename: string; in_app: boolean }[] } }[] };
|
||||
};
|
||||
expect(event.tags).toMatchObject({ runtime: 'server', platform: 'desktop' });
|
||||
const [value] = event.exception.values;
|
||||
expect(value.mechanism.type).toBe('desktop-main');
|
||||
const frame = value.stacktrace.frames.find((f) => f.filename === '/desktop/main.mjs')!;
|
||||
expect(frame.in_app).toBe(true);
|
||||
expect(body).not.toContain('linus');
|
||||
expect(body).not.toContain('acme-corp');
|
||||
expect(body).not.toContain('inbox.json');
|
||||
});
|
||||
|
||||
it('never throws, whatever it is given', async () => {
|
||||
await expect(captureDesktopMainError({ name: 5, message: {}, stack: 7 })).resolves.toBeUndefined();
|
||||
const [entry] = await counted();
|
||||
expect(entry.name).toBe('Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a DSN in a config file', () => {
|
||||
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
|
||||
|
||||
it('never reaches a page of the desktop app before the user said yes', async () => {
|
||||
consent.value = 'off';
|
||||
flag.dsn = DSN;
|
||||
expect(await browserErrorSettings()).toMatchObject({ dsn: '', collect: false });
|
||||
consent.value = 'instant';
|
||||
expect(await browserErrorSettings()).toMatchObject({ dsn: DSN, collect: true });
|
||||
});
|
||||
|
||||
it('stays an admin\'s to set on a server', async () => {
|
||||
flag.IS_DESKTOP_BUILD = false;
|
||||
consent.value = 'off';
|
||||
flag.dsn = DSN;
|
||||
expect(await browserErrorSettings()).toMatchObject({ dsn: DSN, collect: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a server\'s own report', () => {
|
||||
it('has no desktop tag outside the desktop build', async () => {
|
||||
flag.IS_DESKTOP_BUILD = false;
|
||||
flag.dsn = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
|
||||
await captureDesktopMainError(MAIN);
|
||||
const event = JSON.parse(String(fetchSpy.mock.calls[0][1].body).trim().split('\n')[2]) as { tags: Record<string, string> };
|
||||
expect(event.tags.platform).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
captureException, configureErrorReporting, reportFromError, resetErrorReportingForTests,
|
||||
} from '../client';
|
||||
import { appVersion } from '../envelope';
|
||||
import { scrubFramePath, scrubText } from '../scrub';
|
||||
import { parseStack, sanitizeFrames } from '../stack';
|
||||
|
||||
// The desktop app's errors. Main's frames are the shell's own files as /desktop/...: they
|
||||
// have to survive the check that drops reports with no frame of ours, and nothing about the
|
||||
// user's disk may get through with them.
|
||||
|
||||
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
|
||||
const SETTINGS = { dsn: DSN, sampleRate: 1, environment: 'production', collect: false };
|
||||
|
||||
const MAIN_STACK = [
|
||||
"TypeError: Cannot read properties of undefined (reading 'x')",
|
||||
' at sendPing (/desktop/main.mjs:120:7)',
|
||||
' at async /desktop/main.mjs:90:3',
|
||||
' at processTicksAndRejections (node:internal/process/task_queues:105:5)',
|
||||
].join('\n');
|
||||
|
||||
const EXTENSION_STACK = 'TypeError: nope\n at x (chrome-extension://abc/content.js:1:2)\n at y (moz-extension://def/inject.js:3:4)';
|
||||
|
||||
function withStack(stack: string): Error {
|
||||
const error = new TypeError('nope');
|
||||
error.stack = stack;
|
||||
return error;
|
||||
}
|
||||
|
||||
function setBridge(bridge: unknown): void {
|
||||
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = bridge;
|
||||
}
|
||||
|
||||
const forcedOff = { value: false, locked: true, source: 'machine' };
|
||||
|
||||
describe('main-process frames', () => {
|
||||
it('keeps a report whose frames are the shell\'s', () => {
|
||||
const report = reportFromError(withStack(MAIN_STACK), 'desktop-main', false);
|
||||
expect(report).not.toBeNull();
|
||||
expect(report!.frames[0]).toMatchObject({ path: '/desktop/main.mjs', line: 120, column: 7, fn: 'sendPing' });
|
||||
expect(report!.frames[1].path).toBe('/desktop/main.mjs');
|
||||
expect(report!.frames[2].path).toBe('node:internal/process/task_queues');
|
||||
});
|
||||
|
||||
it('keeps one where every frame is the shell\'s', () => {
|
||||
const only = 'TypeError: nope\n at a (/desktop/main.mjs:1:2)\n at b (/desktop/main.mjs:3:4)';
|
||||
expect(reportFromError(withStack(only), 'desktop-main', false)?.frames).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('still drops a report from a browser extension', () => {
|
||||
expect(reportFromError(withStack(EXTENSION_STACK), 'onerror', false)).toBeNull();
|
||||
});
|
||||
|
||||
it('survives being posted and scrubbed again', () => {
|
||||
const frames = parseStack(MAIN_STACK);
|
||||
expect(sanitizeFrames(frames).map((frame) => frame.path)).toEqual(frames.map((frame) => frame.path));
|
||||
});
|
||||
|
||||
it.each([
|
||||
'C:\\Users\\linus\\AppData\\Local\\Programs\\Bulwark\\resources\\app.asar\\dist\\main.mjs',
|
||||
'file:///C:/Users/linus/AppData/Local/Programs/Bulwark/resources/app.asar/dist/main.mjs',
|
||||
'/home/linus/.local/bin/bulwark/resources/app.asar/dist/main.mjs',
|
||||
'/desktop/../../Users/linus/secret.js',
|
||||
'/desktop/C:\\Users\\linus\\x.js',
|
||||
'/desktop/linus smith/x.js',
|
||||
'https://evil.example/desktop/x.js',
|
||||
])('gives nothing away for %s', (input) => {
|
||||
expect(scrubFramePath(input)).toBe('[other]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a report says', () => {
|
||||
it.each([
|
||||
["ENOENT: no such file or directory, open 'C:\\Users\\linus\\AppData\\Roaming\\Bulwark\\crash.dmp'", ['linus', 'AppData', 'crash']],
|
||||
['EPERM: operation not permitted, mkdir C:\\Users\\linus\\mail', ['linus']],
|
||||
['failed to read /home/linus/.config/Bulwark/state', ['linus', '.config']],
|
||||
['Subject Merger plans for Acme failed from ceo.private@acme-corp.example', ['Merger', 'Acme', 'ceo.private', 'acme-corp']],
|
||||
['request failed Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.abcdef sealed', ['eyJ', 'abcdef']],
|
||||
['token 0123456789abcdef0123456789abcdef0123 refused', ['0123456789']],
|
||||
])('%s', (input, gone) => {
|
||||
const out = scrubText(input);
|
||||
for (const word of gone) expect(out).not.toContain(word);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tagged as the desktop app', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
const eventOf = (init: RequestInit) => JSON.parse(String(init.body).trim().split('\n')[2]) as { tags: Record<string, string> };
|
||||
|
||||
beforeEach(() => {
|
||||
resetErrorReportingForTests();
|
||||
sessionStorage.clear();
|
||||
fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
setBridge(undefined);
|
||||
});
|
||||
|
||||
const bundleError = () => withStack('TypeError: nope\n at a (https://mail.example.org/_next/static/chunks/1.js:2:30)');
|
||||
|
||||
it('in the app, on the event that goes to a DSN', () => {
|
||||
setBridge({ version: 1, platform: 'win32' });
|
||||
configureErrorReporting(SETTINGS);
|
||||
captureException(bundleError());
|
||||
expect(eventOf(fetchSpy.mock.calls[0][1]).tags).toMatchObject({ runtime: 'browser', platform: 'desktop' });
|
||||
});
|
||||
|
||||
it('not in a browser', () => {
|
||||
configureErrorReporting(SETTINGS);
|
||||
captureException(bundleError());
|
||||
expect(eventOf(fetchSpy.mock.calls[0][1]).tags.platform).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a page in the app sends nothing with crash reports forced off, even to an instance that asks', () => {
|
||||
setBridge({ version: 1, platform: 'win32', policy: { get: () => ({ CrashReportsAllowed: forcedOff }), onChange: () => () => {} } });
|
||||
configureErrorReporting({ ...SETTINGS, collect: true });
|
||||
captureException(bundleError());
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a policy that only recommends leaves the instance\'s own settings in charge', () => {
|
||||
setBridge({ version: 1, platform: 'win32', policy: { get: () => ({ CrashReportsAllowed: { value: false, locked: false, source: 'recommended' } }), onChange: () => () => {} } });
|
||||
configureErrorReporting(SETTINGS);
|
||||
captureException(bundleError());
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('in the version, in the desktop build only: the collector has no room for a tag', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_VERSION', '1.20.0');
|
||||
expect(appVersion()).toBe('1.20.0');
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
expect(appVersion()).toBe('1.20.0+desktop');
|
||||
expect(appVersion()).toMatch(/^\d+\.\d+\.\d+(?:[-+][\w.-]{1,40})?$/);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { scrubRoutePath } from '@/lib/route-groups';
|
||||
import { debugIdsByPath } from './debug-ids';
|
||||
import {
|
||||
@@ -176,10 +177,19 @@ export function captureReport(report: ErrorReport): void {
|
||||
deliver(withCrumbs);
|
||||
}
|
||||
|
||||
/**
|
||||
* `CrashReportsAllowed` forced off, on any page of the desktop app. An instance's own
|
||||
* settings decide the rest there: its admin chose what its pages report to it.
|
||||
*/
|
||||
function forbiddenByDevicePolicy(): boolean {
|
||||
const entry = getDesktopBridge()?.policy?.get?.().CrashReportsAllowed;
|
||||
return !!entry && entry.locked && entry.value === false;
|
||||
}
|
||||
|
||||
function deliver(report: ErrorReport): void {
|
||||
const s = state();
|
||||
const settings = s.settings;
|
||||
if (!settings) return;
|
||||
if (!settings || forbiddenByDevicePolicy()) return;
|
||||
const version = appVersion();
|
||||
const print = fingerprint(report.name, report.frames, version, report.code);
|
||||
if (!limiter().allow(print)) return;
|
||||
@@ -194,7 +204,12 @@ function deliver(report: ErrorReport): void {
|
||||
platform: 'javascript',
|
||||
fingerprint: print,
|
||||
debugIds: debugIdsByPath(),
|
||||
tags: { runtime: 'browser', ...(process.env.NEXT_PUBLIC_BULWARK_LITE === '1' ? { lite: 'yes' } : {}) },
|
||||
tags: {
|
||||
runtime: 'browser',
|
||||
...(process.env.NEXT_PUBLIC_BULWARK_LITE === '1' ? { lite: 'yes' } : {}),
|
||||
// An instance's build knows nothing of the app it's in, so the bridge says.
|
||||
...(getDesktopBridge() ? { platform: 'desktop' } : {}),
|
||||
},
|
||||
});
|
||||
send(envelopeUrl(dsn), buildEnvelope(event), {
|
||||
// text/plain keeps it a simple request: no preflight, no custom header.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DESKTOP_FRAME_PREFIX, DESKTOP_VERSION_SUFFIX } from '@/lib/desktop/desktop-telemetry';
|
||||
import type { Frame } from './stack';
|
||||
|
||||
/**
|
||||
@@ -93,7 +94,7 @@ export function buildEvent(report: ErrorReport, ctx: EventContext): Record<strin
|
||||
...(frame.fn ? { function: frame.fn } : {}),
|
||||
...(frame.line !== undefined ? { lineno: frame.line } : {}),
|
||||
...(frame.column !== undefined ? { colno: frame.column } : {}),
|
||||
in_app: frame.path.startsWith('/_next/') || frame.path.startsWith('/.next/'),
|
||||
in_app: frame.path.startsWith('/_next/') || frame.path.startsWith('/.next/') || frame.path.startsWith(DESKTOP_FRAME_PREFIX),
|
||||
}));
|
||||
const images = [...new Set(report.frames.map((frame) => frame.path))]
|
||||
.filter((path) => ctx.debugIds.has(path))
|
||||
@@ -144,6 +145,11 @@ export function releaseName(): string {
|
||||
return commit && commit !== 'unknown' ? `${version}+${commit}` : version;
|
||||
}
|
||||
|
||||
/**
|
||||
* In the desktop build with a suffix, which also reaches the collector: its error batches
|
||||
* have no room for a tag, and its version field allows this.
|
||||
*/
|
||||
export function appVersion(): string {
|
||||
return process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
const version = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
return process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? `${version}${DESKTOP_VERSION_SUFFIX}` : version;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* Documented at https://bulwarkmail.org/docs/deployment/monitoring#what-is-removed
|
||||
*/
|
||||
|
||||
import { DESKTOP_FRAME_PREFIX } from '@/lib/desktop/desktop-telemetry';
|
||||
|
||||
export const MAX_MESSAGE_LENGTH = 200;
|
||||
|
||||
const REDACTED = '…';
|
||||
@@ -130,12 +132,15 @@ export function scrubFunctionName(input: unknown): string | undefined {
|
||||
* The path of a stack frame's file, without origin, base path, query or
|
||||
* fragment: `/_next/static/chunks/x.js` for the browser bundle,
|
||||
* `/.next/server/...` for the server's, `node:...` for Node's own modules,
|
||||
* `node_modules/...` for dependencies; `[other]` for anything else
|
||||
* `node_modules/...` for dependencies, `/desktop/...` for the desktop app's main
|
||||
* process, which the shell rewrites its own files to, so a path on disk never gets
|
||||
* here; `[other]` for anything else
|
||||
* (extensions, plugin code, evaluated code).
|
||||
*/
|
||||
export function scrubFramePath(input: unknown): string {
|
||||
if (typeof input !== 'string' || input === '') return '[other]';
|
||||
const path = input.split(/[?#]/, 1)[0].replace(/\\/g, '/');
|
||||
if (path.startsWith(DESKTOP_FRAME_PREFIX)) return /^[\w./@+~-]+$/.test(path) && !path.includes('..') ? path : '[other]';
|
||||
for (const marker of ['/_next/static/', '/.next/server/', '/.next/static/']) {
|
||||
const at = path.indexOf(marker);
|
||||
if (at >= 0) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { errorReportingSettings, shouldCollectErrors } from './settings';
|
||||
import { scrubName, scrubText } from './scrub';
|
||||
import { parseStack, type Frame } from './stack';
|
||||
import { debugIdsByPath } from './debug-ids';
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
|
||||
/**
|
||||
* Error reporting on the server: errors Next hands to onRequestError
|
||||
@@ -56,7 +57,7 @@ async function deliver(report: ErrorReport): Promise<void> {
|
||||
platform: 'node',
|
||||
fingerprint: print,
|
||||
debugIds: debugIdsByPath(),
|
||||
tags: { runtime: 'server' },
|
||||
tags: { runtime: 'server', ...(IS_DESKTOP_BUILD ? { platform: 'desktop' } : {}) },
|
||||
});
|
||||
s.sending++;
|
||||
try {
|
||||
@@ -95,6 +96,31 @@ export function captureRequestError(error: unknown, routePattern?: string): Prom
|
||||
}
|
||||
}
|
||||
|
||||
export interface DesktopMainErrorInput {
|
||||
name?: unknown;
|
||||
message?: unknown;
|
||||
stack?: unknown;
|
||||
mechanism?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* An error in the desktop app's main process, handed over by the shell. Its frames are
|
||||
* the shell's own files as `/desktop/...`. All of it is scrubbed here again.
|
||||
*/
|
||||
export function captureDesktopMainError(error: DesktopMainErrorInput): Promise<void> {
|
||||
try {
|
||||
return handleServerReport({
|
||||
name: scrubName(error.name),
|
||||
message: scrubText(error.message),
|
||||
frames: parseStack(error.stack),
|
||||
handled: false,
|
||||
mechanism: error.mechanism === 'unhandledrejection' ? 'desktop-main-rejection' : 'desktop-main',
|
||||
});
|
||||
} catch {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* logger.error: a route that caught an error and logged it. The frames are
|
||||
* the logging call's, minus the logger's own `skip` frames.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { configManager } from '@/lib/admin/config-manager';
|
||||
import { parseDsn } from './envelope';
|
||||
import { metricsEnabled } from '@/lib/metrics';
|
||||
import { effectiveErrorsConsent } from '@/lib/telemetry/state';
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import type { ClientSettings } from './client';
|
||||
|
||||
/**
|
||||
@@ -45,6 +46,8 @@ export function errorReportingSettings(): ErrorReportingSettings {
|
||||
*/
|
||||
export async function shouldCollectErrors(): Promise<boolean> {
|
||||
await configManager.ensureLoaded();
|
||||
// The desktop user's own yes, and nothing else: a DSN in a config file isn't consent.
|
||||
if (IS_DESKTOP_BUILD) return (await effectiveErrorsConsent()).consent !== 'off';
|
||||
if (errorReportingSettings().dsn || metricsEnabled()) return true;
|
||||
return (await effectiveErrorsConsent()).consent !== 'off';
|
||||
}
|
||||
@@ -52,5 +55,7 @@ export async function shouldCollectErrors(): Promise<boolean> {
|
||||
/** What /api/config hands the browser. */
|
||||
export async function browserErrorSettings(): Promise<ClientSettings> {
|
||||
const settings = errorReportingSettings();
|
||||
return { ...settings, collect: await shouldCollectErrors() };
|
||||
const collect = await shouldCollectErrors();
|
||||
// A browser sends to the DSN by itself, so it mustn't get one before the user said yes.
|
||||
return { ...settings, dsn: IS_DESKTOP_BUILD && !collect ? '' : settings.dsn, collect };
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ export function parseStack(stack: unknown): Frame[] {
|
||||
for (const line of stack.split('\n')) {
|
||||
const match = V8.exec(line) ?? GECKO.exec(line);
|
||||
if (!match) continue;
|
||||
const [, fn, file, lineNo, column] = match;
|
||||
const [, fn, rawFile, lineNo, column] = match;
|
||||
// "at async file:1:2": an anonymous async frame.
|
||||
const file = rawFile.replace(/^async /, '');
|
||||
// "at fn (native)" and eval chains carry no file worth keeping.
|
||||
frames.push({
|
||||
path: scrubFramePath(file),
|
||||
|
||||
@@ -5,6 +5,8 @@ import { effectiveErrorsConsent, getInstanceId } from './state';
|
||||
import { getLoginCounts } from './login-tracker';
|
||||
import { isErrorCode } from '@/lib/error-reporting/envelope';
|
||||
import { readErrors, utcDay, type Aggregate } from '@/lib/error-reporting/aggregate';
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import { desktopServerPrivacy } from '@/lib/desktop/server-privacy';
|
||||
import {
|
||||
MAX_TELEMETRY_ERRORS,
|
||||
MAX_TELEMETRY_FRAMES,
|
||||
@@ -23,6 +25,10 @@ export function markProcessStart(): void {
|
||||
}
|
||||
|
||||
function readPackage(): { version: string; build: string | null } {
|
||||
// The desktop app's version, not the webmail's it carries, and mode and channel in the
|
||||
// one field the collector keeps (lib/desktop/desktop-telemetry.ts).
|
||||
const desktop = desktopServerPrivacy();
|
||||
if (desktop) return { version: desktop.appVersion, build: desktop.build };
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
|
||||
@@ -34,6 +40,7 @@ function readPackage(): { version: string; build: string | null } {
|
||||
}
|
||||
|
||||
function detectPlatform(): Platform {
|
||||
if (IS_DESKTOP_BUILD) return 'desktop';
|
||||
if (process.env.KUBERNETES_SERVICE_HOST) return 'k8s';
|
||||
// /.dockerenv is the standard Docker container marker.
|
||||
try {
|
||||
@@ -154,6 +161,8 @@ export function errorsForDay(agg: Aggregate, day: string): TelemetryError[] {
|
||||
*/
|
||||
export async function buildPayload(): Promise<AnyTelemetryPayload> {
|
||||
const base = await buildPayloadV1();
|
||||
// Crash report consent has its own way out, /v1/errors. A heartbeat is only telemetry.
|
||||
if (IS_DESKTOP_BUILD) return base;
|
||||
// Daily and instant mode both send the last complete day with the heartbeat.
|
||||
if ((await effectiveErrorsConsent()).consent === 'off') return base;
|
||||
return { ...base, schema: '2', errors: errorsForPayload(await readErrors()) };
|
||||
|
||||
+11
-3
@@ -7,6 +7,8 @@ import {
|
||||
import { buildPayload } from './payload';
|
||||
import { fetchTelemetryTarget, resolveEndpointAllowed } from './endpoint-guard';
|
||||
import { DEFAULT_ENDPOINT } from './types';
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import { DESKTOP_CATCH_UP_MS, DESKTOP_RETRY_MS } from '@/lib/desktop/desktop-telemetry';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const JITTER_MS = 2 * 60 * 60 * 1000; // ± 2 hours
|
||||
@@ -164,12 +166,16 @@ async function scheduleNext(delayMs: number, generation: number): Promise<void>
|
||||
}
|
||||
|
||||
async function tick(slot: string, generation: number): Promise<void> {
|
||||
let failed = false;
|
||||
try {
|
||||
await sendOnce({ reason: 'scheduled', slot });
|
||||
const result = await sendOnce({ reason: 'scheduled', slot });
|
||||
failed = !result.ok && result.error !== 'already sent';
|
||||
} catch (err) {
|
||||
failed = true;
|
||||
logger.warn('telemetry: heartbeat failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
await scheduleNext(jitteredDelay(DAY_MS), generation);
|
||||
// A laptop that woke up offline shouldn't lose the day.
|
||||
await scheduleNext(IS_DESKTOP_BUILD && failed ? DESKTOP_RETRY_MS : jitteredDelay(DAY_MS), generation);
|
||||
}
|
||||
|
||||
// Run by the scheduler leader (lib/scheduler-leader.ts). Idempotent.
|
||||
@@ -187,7 +193,9 @@ export async function startScheduler(): Promise<void> {
|
||||
}
|
||||
// If we have a next-scheduled time in the future use it; otherwise schedule
|
||||
// FIRST_DELAY_MS out. This means after a restart we don't fire immediately.
|
||||
let delay = FIRST_DELAY_MS;
|
||||
// A desktop app isn't a server: quit within the hour on every start, and it would
|
||||
// never send. So what's due goes out soon after a start.
|
||||
let delay = IS_DESKTOP_BUILD ? DESKTOP_CATCH_UP_MS : FIRST_DELAY_MS;
|
||||
if (state.nextScheduledAt) {
|
||||
const remaining = new Date(state.nextScheduledAt).getTime() - Date.now();
|
||||
if (remaining > 0) delay = Math.min(remaining, DAY_MS + JITTER_MS);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { isStoreUnavailable, StoredDataUnreadableError } from '@/lib/stored-data
|
||||
import type { TelemetryStateFile, ConsentState, ErrorsConsent } from './types';
|
||||
import { DEFAULT_ENDPOINT } from './types';
|
||||
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import { desktopServerPrivacy } from '@/lib/desktop/server-privacy';
|
||||
|
||||
export function getDir(): string {
|
||||
return process.env.TELEMETRY_DATA_DIR ||
|
||||
@@ -195,6 +197,9 @@ export async function effectiveErrorsConsent(): Promise<{
|
||||
}> {
|
||||
const telemetry = await effectiveConsent();
|
||||
if (telemetry.source === 'no-third-party') return { consent: 'off', source: 'no-third-party' };
|
||||
// The desktop user's crash report consent, separate from telemetry: either can be on
|
||||
// without the other. Instant, because without a heartbeat nothing else carries them.
|
||||
if (IS_DESKTOP_BUILD) return { consent: desktopServerPrivacy()?.crashReports ? 'instant' : 'off', source: 'env' };
|
||||
if (telemetry.consent !== 'on') return { consent: 'off', source: 'telemetry' };
|
||||
const env = errorsEnvOverride();
|
||||
if (env) return { consent: env, source: 'env' };
|
||||
@@ -213,6 +218,8 @@ export async function effectiveConsent(): Promise<{
|
||||
const envState = envOverride();
|
||||
const state = await loadState();
|
||||
if (await isNoThirdPartyTrafficOn()) return { consent: 'off', source: 'no-third-party', state };
|
||||
// The user answers in the shell, and nothing on disk or in the environment beats that.
|
||||
if (IS_DESKTOP_BUILD) return { consent: desktopServerPrivacy()?.telemetry ? 'on' : 'off', source: 'env', state };
|
||||
if (envState) return { consent: envState, source: 'env', state };
|
||||
return { consent: state.consent, source: 'file', state };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
export type ConsentState = 'pending' | 'on' | 'off';
|
||||
|
||||
export type Platform = 'docker' | 'bare' | 'k8s' | 'unknown';
|
||||
export type Platform = 'docker' | 'bare' | 'k8s' | 'desktop' | 'unknown';
|
||||
export type OsFamily = 'linux' | 'darwin' | 'windows' | 'unknown';
|
||||
export type CountBucket = '0' | '1' | '2-5' | '6-10' | '11-50' | '51-200' | '201+';
|
||||
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "أوقفت مؤسستك الإضافات على هذا الجهاز.",
|
||||
"plugins_allowlist": "لا تسمح مؤسستك إلا بإضافات معينة على هذا الجهاز.",
|
||||
"off": "إيقاف"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "معلومات الاستخدام وتقارير الأعطال",
|
||||
"section_description": "كلاهما متوقف حتى تشغّله، ولكل منهما مفتاحه الخاص.",
|
||||
"telemetry_label": "إرسال معلومات الاستخدام",
|
||||
"telemetry_description": "مرة واحدة في اليوم: إصدار التطبيق ونظام التشغيل وطريقة اتصال التطبيق. ومع خادم بريد خاص بك، أيضًا الميزات المفعّلة وعدد تقريبي للحسابات. لا يُرسل أبدًا البريد أو العناوين أو أسماء الخوادم.",
|
||||
"crash_label": "إرسال تقارير الأعطال",
|
||||
"crash_description": "تُرسل أخطاء التطبيق مع أسماء ملفات من شيفرة التطبيق نفسه ورسائل قصيرة منقّحة. وإذا تعطّل التطبيق نفسه، يبقى ملف العطل على هذا الحاسوب ولا يغادره إلا إذا أرسلته.",
|
||||
"crash_memory": "ملف العطل نسخة من جزء من ذاكرة التطبيق لحظة العطل، لذا قد يحتوي على أجزاء من رسائل مفتوحة. يزيل Bulwark ما يجده من أسماء المستخدمين والعناوين ورموز تسجيل الدخول، لكنه لا يَعِد بالعثور على كل شيء.",
|
||||
"instance_note": "على خادم Bulwark، يقرر مسؤول ذلك الخادم بشكل منفصل ما تبلّغه صفحاته به.",
|
||||
"forced_off": "أوقفت مؤسستك هذا الخيار.",
|
||||
"url_label": "وجهة ملفات الأعطال",
|
||||
"url_description": "عنوان https الذي يستقبل ملف العطل. لا يأتي Bulwark بعنوان.",
|
||||
"url_invalid": "أدخل عنوان https كاملًا.",
|
||||
"url_save": "حفظ",
|
||||
"files_label": "ملفات الأعطال على هذا الحاسوب",
|
||||
"files_description": "يحتفظ Bulwark بالأحدث ويحذف ما مضى عليه أكثر من شهر.",
|
||||
"files_empty": "لا توجد ملفات أعطال.",
|
||||
"file_size": "{size} ك.ب",
|
||||
"send": "إرسال تقرير العطل…",
|
||||
"delete_all": "حذف الكل",
|
||||
"sent": "أُرسل تقرير العطل.",
|
||||
"send_no_consent": "شغّل تقارير الأعطال أولًا.",
|
||||
"send_no_url": "لم يُحدَّد عنوان، لذا لا توجد وجهة للإرسال. أدخل عنوانًا في الأعلى.",
|
||||
"send_failed": "تعذّر إرسال تقرير العطل."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "La teva organització ha desactivat els connectors en aquest dispositiu.",
|
||||
"plugins_allowlist": "La teva organització només permet determinats connectors en aquest dispositiu.",
|
||||
"off": "Desactivat"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Dades d'ús i informes d'errors greus",
|
||||
"section_description": "Tots dos estan desactivats fins que els activis, i cadascun té el seu interruptor.",
|
||||
"telemetry_label": "Envia dades d'ús",
|
||||
"telemetry_description": "Un cop al dia: la versió de l'aplicació, el teu sistema operatiu i com es connecta l'aplicació. Amb un servidor de correu propi, també quines funcions estan actives i un nombre aproximat de comptes. Mai correus, adreces ni noms de servidor.",
|
||||
"crash_label": "Envia informes d'errors greus",
|
||||
"crash_description": "Els errors de l'aplicació s'envien amb noms de fitxer del codi de l'aplicació i missatges curts i nets. Si la mateixa aplicació es tanca per un error, un fitxer d'error queda en aquest ordinador i només en surt si l'envies.",
|
||||
"crash_memory": "Un fitxer d'error és una còpia d'una part de la memòria de l'aplicació en el moment de l'error, de manera que pot contenir parts de correus oberts. Bulwark elimina els noms d'usuari, les adreces i els testimonis d'inici de sessió que troba, però no pot prometre trobar-ho tot.",
|
||||
"instance_note": "En un servidor Bulwark, el seu administrador decideix per separat què li comuniquen les pàgines.",
|
||||
"forced_off": "La teva organització ho ha desactivat.",
|
||||
"url_label": "Destinació dels fitxers d'error",
|
||||
"url_description": "L'adreça https que rep un fitxer d'error. Bulwark no n'inclou cap.",
|
||||
"url_invalid": "Escriu una adreça https completa.",
|
||||
"url_save": "Desa",
|
||||
"files_label": "Fitxers d'error en aquest ordinador",
|
||||
"files_description": "Bulwark conserva els més recents i elimina els de més d'un mes.",
|
||||
"files_empty": "No hi ha fitxers d'error.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Envia l'informe d'error…",
|
||||
"delete_all": "Elimina-ho tot",
|
||||
"sent": "Informe d'error enviat.",
|
||||
"send_no_consent": "Activa primer els informes d'errors greus.",
|
||||
"send_no_url": "No hi ha cap adreça, així que no hi ha on enviar-ho. Escriu-ne una a dalt.",
|
||||
"send_failed": "No s'ha pogut enviar l'informe d'error."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Vaše organizace na tomto zařízení vypnula pluginy.",
|
||||
"plugins_allowlist": "Vaše organizace na tomto zařízení povoluje jen některé pluginy.",
|
||||
"off": "Vypnuto"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Údaje o používání a hlášení o pádech",
|
||||
"section_description": "Obojí je vypnuté, dokud to nezapnete, a každé má vlastní přepínač.",
|
||||
"telemetry_label": "Odesílat údaje o používání",
|
||||
"telemetry_description": "Jednou denně: verze aplikace, váš operační systém a způsob připojení aplikace. U vlastního poštovního serveru také zapnuté funkce a přibližný počet účtů. Nikdy pošta, adresy ani názvy serverů.",
|
||||
"crash_label": "Odesílat hlášení o pádech",
|
||||
"crash_description": "Chyby v aplikaci se odesílají s názvy souborů z kódu aplikace a krátkými, očištěnými zprávami. Když spadne sama aplikace, soubor o pádu zůstane v tomto počítači a odejde jen tehdy, když ho odešlete.",
|
||||
"crash_memory": "Soubor o pádu je kopie části paměti aplikace v okamžiku pádu, takže může obsahovat části otevřené pošty. Bulwark odstraní uživatelská jména, adresy a přihlašovací tokeny, které najde, ale nemůže slíbit, že najde vše.",
|
||||
"instance_note": "Na serveru Bulwark rozhoduje jeho správce zvlášť o tom, co mu stránky hlásí.",
|
||||
"forced_off": "Vaše organizace to vypnula.",
|
||||
"url_label": "Kam jdou soubory o pádu",
|
||||
"url_description": "Adresa https, která přijímá soubor o pádu. Bulwark žádnou nemá.",
|
||||
"url_invalid": "Zadejte úplnou adresu https.",
|
||||
"url_save": "Uložit",
|
||||
"files_label": "Soubory o pádu v tomto počítači",
|
||||
"files_description": "Bulwark ponechá ty nejnovější a smaže ty starší než měsíc.",
|
||||
"files_empty": "Žádné soubory o pádu.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Odeslat hlášení o pádu…",
|
||||
"delete_all": "Smazat vše",
|
||||
"sent": "Hlášení o pádu odesláno.",
|
||||
"send_no_consent": "Nejprve zapněte hlášení o pádech.",
|
||||
"send_no_url": "Není nastavena žádná adresa, takže to nejde kam odeslat. Zadejte ji výše.",
|
||||
"send_failed": "Hlášení o pádu se nepodařilo odeslat."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Din organisation har slået plugins fra på denne enhed.",
|
||||
"plugins_allowlist": "Din organisation tillader kun bestemte plugins på denne enhed.",
|
||||
"off": "Fra"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Brugsdata og nedbrudsrapporter",
|
||||
"section_description": "Begge er slået fra, indtil du slår dem til, og hver har sin egen kontakt.",
|
||||
"telemetry_label": "Send brugsdata",
|
||||
"telemetry_description": "Én gang om dagen: appens version, dit styresystem og hvordan appen forbinder. Med din egen mailserver også hvilke funktioner der er slået til og et omtrentligt antal konti. Aldrig mails, adresser eller servernavne.",
|
||||
"crash_label": "Send nedbrudsrapporter",
|
||||
"crash_description": "Fejl i appen sendes med filnavne fra appens egen kode og korte, rensede beskeder. Hvis selve appen går ned, bliver en nedbrudsfil på denne computer og forlader den kun, hvis du sender den.",
|
||||
"crash_memory": "En nedbrudsfil er en kopi af en del af appens hukommelse, da den gik ned, så den kan indeholde dele af åbne mails. Bulwark fjerner de brugernavne, adresser og login-tokens, det finder, men kan ikke love at finde alt.",
|
||||
"instance_note": "På en Bulwark-server bestemmer dens administrator særskilt, hvad siderne melder til den.",
|
||||
"forced_off": "Din organisation har slået dette fra.",
|
||||
"url_label": "Hvor nedbrudsfiler sendes hen",
|
||||
"url_description": "Den https-adresse, der modtager en nedbrudsfil. Bulwark har ingen med.",
|
||||
"url_invalid": "Skriv en fuld https-adresse.",
|
||||
"url_save": "Gem",
|
||||
"files_label": "Nedbrudsfiler på denne computer",
|
||||
"files_description": "Bulwark beholder de nyeste og sletter dem, der er ældre end en måned.",
|
||||
"files_empty": "Ingen nedbrudsfiler.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Send nedbrudsrapport…",
|
||||
"delete_all": "Slet alle",
|
||||
"sent": "Nedbrudsrapport sendt.",
|
||||
"send_no_consent": "Slå først nedbrudsrapporter til.",
|
||||
"send_no_url": "Der er ikke angivet en adresse, så den kan ikke sendes nogen steder. Skriv en ovenfor.",
|
||||
"send_failed": "Nedbrudsrapporten kunne ikke sendes."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Ihre Organisation hat Plugins auf diesem Gerät ausgeschaltet.",
|
||||
"plugins_allowlist": "Ihre Organisation erlaubt auf diesem Gerät nur bestimmte Plugins.",
|
||||
"off": "Aus"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Nutzungsdaten und Absturzberichte",
|
||||
"section_description": "Beides bleibt aus, bis Sie es einschalten, und jedes hat einen eigenen Schalter.",
|
||||
"telemetry_label": "Nutzungsdaten senden",
|
||||
"telemetry_description": "Einmal am Tag: die App-Version, Ihr Betriebssystem und die Art der Verbindung. Bei einem eigenen Mailserver auch, welche Funktionen aktiv sind, und eine ungefähre Zahl der Konten. Niemals E-Mails, Adressen oder Servernamen.",
|
||||
"crash_label": "Absturzberichte senden",
|
||||
"crash_description": "Fehler in der App werden mit Dateinamen aus dem eigenen Code der App und kurzen, bereinigten Meldungen gesendet. Stürzt die App selbst ab, bleibt eine Absturzdatei auf diesem Computer und verlässt ihn nur, wenn Sie sie senden.",
|
||||
"crash_memory": "Eine Absturzdatei ist eine Kopie eines Teils des Arbeitsspeichers der App beim Absturz und kann daher Teile geöffneter E-Mails enthalten. Bulwark entfernt Benutzernamen, Adressen und Anmeldetokens, die es findet, kann aber nicht versprechen, alles zu finden.",
|
||||
"instance_note": "Bei einem Bulwark-Server entscheidet dessen Administrator getrennt davon, was seine Seiten an ihn melden.",
|
||||
"forced_off": "Ihre Organisation hat das ausgeschaltet.",
|
||||
"url_label": "Ziel der Absturzdateien",
|
||||
"url_description": "Die https-Adresse, die eine Absturzdatei empfängt. Bulwark bringt keine mit.",
|
||||
"url_invalid": "Geben Sie eine vollständige https-Adresse ein.",
|
||||
"url_save": "Speichern",
|
||||
"files_label": "Absturzdateien auf diesem Computer",
|
||||
"files_description": "Bulwark behält die neuesten und löscht alles, was älter als einen Monat ist.",
|
||||
"files_empty": "Keine Absturzdateien.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Absturzbericht senden…",
|
||||
"delete_all": "Alle löschen",
|
||||
"sent": "Absturzbericht gesendet.",
|
||||
"send_no_consent": "Schalten Sie zuerst die Absturzberichte ein.",
|
||||
"send_no_url": "Es ist keine Adresse eingetragen, daher gibt es kein Ziel. Geben Sie oben eine ein.",
|
||||
"send_failed": "Der Absturzbericht konnte nicht gesendet werden."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Your organisation turned off plugins on this device.",
|
||||
"plugins_allowlist": "Your organisation only allows certain plugins on this device.",
|
||||
"off": "Off"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Usage info and crash reports",
|
||||
"section_description": "Both are off until you turn them on, and each has its own switch.",
|
||||
"telemetry_label": "Send usage info",
|
||||
"telemetry_description": "Once a day: the app version, your operating system and how the app connects. With your own mail server, also which features are on and a rough number of accounts. Never mail, addresses or server names.",
|
||||
"crash_label": "Send crash reports",
|
||||
"crash_description": "Errors in the app go out with file names from the app's own code and short, cleaned-up messages. If the app itself crashes, a crash file stays on this computer and only leaves when you send it.",
|
||||
"crash_memory": "A crash file is a copy of part of the app's memory when it crashed, so it can contain parts of open mail. Bulwark removes user names, addresses and sign-in tokens it can find, but it can't promise to find everything.",
|
||||
"instance_note": "On a Bulwark server, that server's admin decides separately what its pages report to it.",
|
||||
"forced_off": "Your organisation turned this off.",
|
||||
"url_label": "Where crash files go",
|
||||
"url_description": "The https address that receives a crash file. Bulwark doesn't come with one.",
|
||||
"url_invalid": "Enter a full https address.",
|
||||
"url_save": "Save",
|
||||
"files_label": "Crash files on this computer",
|
||||
"files_description": "Bulwark keeps the newest few and deletes ones older than a month.",
|
||||
"files_empty": "No crash files.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Send crash report…",
|
||||
"delete_all": "Delete all",
|
||||
"sent": "Crash report sent.",
|
||||
"send_no_consent": "Turn on crash reports first.",
|
||||
"send_no_url": "No address is set, so there's nowhere to send this. Enter one above.",
|
||||
"send_failed": "The crash report couldn't be sent."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Tu organización ha desactivado los complementos en este dispositivo.",
|
||||
"plugins_allowlist": "Tu organización solo permite determinados complementos en este dispositivo.",
|
||||
"off": "Desactivado"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Datos de uso e informes de fallos",
|
||||
"section_description": "Ambos están desactivados hasta que los actives, y cada uno tiene su propio interruptor.",
|
||||
"telemetry_label": "Enviar datos de uso",
|
||||
"telemetry_description": "Una vez al día: la versión de la aplicación, tu sistema operativo y cómo se conecta la aplicación. Con tu propio servidor de correo, también qué funciones están activas y un número aproximado de cuentas. Nunca correos, direcciones ni nombres de servidor.",
|
||||
"crash_label": "Enviar informes de fallos",
|
||||
"crash_description": "Los errores de la aplicación se envían con nombres de archivo del propio código de la aplicación y mensajes breves y limpios. Si la propia aplicación falla, un archivo de fallo queda en este ordenador y solo sale de él si lo envías.",
|
||||
"crash_memory": "Un archivo de fallo es una copia de parte de la memoria de la aplicación en el momento del fallo, así que puede contener partes de correos abiertos. Bulwark elimina los nombres de usuario, las direcciones y los tokens de inicio de sesión que encuentra, pero no puede prometer encontrarlo todo.",
|
||||
"instance_note": "En un servidor Bulwark, su administrador decide por separado qué le comunican sus páginas.",
|
||||
"forced_off": "Tu organización ha desactivado esto.",
|
||||
"url_label": "Destino de los archivos de fallo",
|
||||
"url_description": "La dirección https que recibe un archivo de fallo. Bulwark no incluye ninguna.",
|
||||
"url_invalid": "Escribe una dirección https completa.",
|
||||
"url_save": "Guardar",
|
||||
"files_label": "Archivos de fallo en este ordenador",
|
||||
"files_description": "Bulwark conserva los más recientes y elimina los de más de un mes.",
|
||||
"files_empty": "No hay archivos de fallo.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Enviar informe de fallo…",
|
||||
"delete_all": "Eliminar todo",
|
||||
"sent": "Informe de fallo enviado.",
|
||||
"send_no_consent": "Activa primero los informes de fallos.",
|
||||
"send_no_url": "No hay ninguna dirección, así que no hay adónde enviarlo. Escribe una arriba.",
|
||||
"send_failed": "No se ha podido enviar el informe de fallo."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Sinu organisatsioon on selles seadmes pistikprogrammid välja lülitanud.",
|
||||
"plugins_allowlist": "Sinu organisatsioon lubab selles seadmes ainult teatud pistikprogramme.",
|
||||
"off": "Väljas"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Kasutusandmed ja kokkujooksmise aruanded",
|
||||
"section_description": "Mõlemad on välja lülitatud, kuni need sisse lülitate, ja kummalgi on oma lüliti.",
|
||||
"telemetry_label": "Saada kasutusandmed",
|
||||
"telemetry_description": "Kord päevas: rakenduse versioon, teie operatsioonisüsteem ja rakenduse ühendusviis. Oma meiliserveri puhul ka see, millised funktsioonid on sees, ja kontode ligikaudne arv. Mitte kunagi kirju, aadresse ega serverinimesid.",
|
||||
"crash_label": "Saada kokkujooksmise aruanded",
|
||||
"crash_description": "Rakenduse vead saadetakse koos rakenduse enda koodi failinimede ja lühikeste puhastatud teadetega. Kui rakendus ise kokku jookseb, jääb kokkujooksmise fail sellesse arvutisse ja lahkub sealt ainult siis, kui selle saadate.",
|
||||
"crash_memory": "Kokkujooksmise fail on koopia osast rakenduse mälust kokkujooksmise hetkel, seega võib see sisaldada avatud kirjade osi. Bulwark eemaldab leitud kasutajanimed, aadressid ja sisselogimislubad, kuid ei saa lubada, et leiab kõik.",
|
||||
"instance_note": "Bulwarki serveris otsustab selle administraator eraldi, mida lehed talle teatavad.",
|
||||
"forced_off": "Teie organisatsioon on selle välja lülitanud.",
|
||||
"url_label": "Kuhu kokkujooksmise failid lähevad",
|
||||
"url_description": "Https-aadress, mis võtab kokkujooksmise faili vastu. Bulwarkil pole oma aadressi.",
|
||||
"url_invalid": "Sisestage täielik https-aadress.",
|
||||
"url_save": "Salvesta",
|
||||
"files_label": "Kokkujooksmise failid selles arvutis",
|
||||
"files_description": "Bulwark hoiab alles uusimad ja kustutab kuust vanemad.",
|
||||
"files_empty": "Kokkujooksmise faile pole.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Saada kokkujooksmise aruanne…",
|
||||
"delete_all": "Kustuta kõik",
|
||||
"sent": "Kokkujooksmise aruanne saadetud.",
|
||||
"send_no_consent": "Lülitage esmalt kokkujooksmise aruanded sisse.",
|
||||
"send_no_url": "Aadressi pole määratud, seega pole seda kuhugi saata. Sisestage see ülal.",
|
||||
"send_failed": "Kokkujooksmise aruannet ei õnnestunud saata."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "سازمان شما افزونهها را در این دستگاه خاموش کرده است.",
|
||||
"plugins_allowlist": "سازمان شما در این دستگاه فقط برخی افزونهها را مجاز میداند.",
|
||||
"off": "خاموش"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "اطلاعات استفاده و گزارشهای خرابی",
|
||||
"section_description": "هر دو خاموش هستند تا زمانی که روشنشان کنید و هرکدام کلید خودش را دارد.",
|
||||
"telemetry_label": "ارسال اطلاعات استفاده",
|
||||
"telemetry_description": "روزی یک بار: نسخهٔ برنامه، سیستمعامل شما و نحوهٔ اتصال برنامه. با سرور ایمیل شخصی، همچنین قابلیتهای روشن و تعداد تقریبی حسابها. هرگز ایمیل، نشانی یا نام سرور ارسال نمیشود.",
|
||||
"crash_label": "ارسال گزارشهای خرابی",
|
||||
"crash_description": "خطاهای برنامه همراه با نام فایلهای کد خود برنامه و پیامهای کوتاه پاکسازیشده ارسال میشوند. اگر خود برنامه از کار بیفتد، یک فایل خرابی روی این رایانه میماند و فقط وقتی شما ارسالش کنید خارج میشود.",
|
||||
"crash_memory": "فایل خرابی کپی بخشی از حافظهٔ برنامه در لحظهٔ خرابی است، پس ممکن است بخشهایی از ایمیلهای باز را داشته باشد. Bulwark نامهای کاربری، نشانیها و توکنهای ورودی را که پیدا کند حذف میکند، اما نمیتواند قول دهد همه را پیدا کند.",
|
||||
"instance_note": "در سرور Bulwark، مدیر آن سرور جداگانه تصمیم میگیرد صفحهها چه چیزی به آن گزارش دهند.",
|
||||
"forced_off": "سازمان شما این را خاموش کرده است.",
|
||||
"url_label": "مقصد فایلهای خرابی",
|
||||
"url_description": "نشانی https که فایل خرابی را دریافت میکند. Bulwark نشانی پیشفرضی ندارد.",
|
||||
"url_invalid": "یک نشانی https کامل وارد کنید.",
|
||||
"url_save": "ذخیره",
|
||||
"files_label": "فایلهای خرابی روی این رایانه",
|
||||
"files_description": "Bulwark جدیدترینها را نگه میدارد و قدیمیتر از یک ماه را حذف میکند.",
|
||||
"files_empty": "فایل خرابی وجود ندارد.",
|
||||
"file_size": "{size} کیلوبایت",
|
||||
"send": "ارسال گزارش خرابی…",
|
||||
"delete_all": "حذف همه",
|
||||
"sent": "گزارش خرابی ارسال شد.",
|
||||
"send_no_consent": "ابتدا گزارشهای خرابی را روشن کنید.",
|
||||
"send_no_url": "نشانیای تنظیم نشده، پس جایی برای ارسال نیست. در بالا یکی وارد کنید.",
|
||||
"send_failed": "گزارش خرابی ارسال نشد."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Votre organisation a désactivé les plugins sur cet appareil.",
|
||||
"plugins_allowlist": "Votre organisation n'autorise que certains plugins sur cet appareil.",
|
||||
"off": "Désactivé"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Données d'utilisation et rapports de plantage",
|
||||
"section_description": "Les deux restent désactivés tant que vous ne les activez pas, et chacun a son propre interrupteur.",
|
||||
"telemetry_label": "Envoyer les données d'utilisation",
|
||||
"telemetry_description": "Une fois par jour : la version de l'application, votre système d'exploitation et le mode de connexion. Avec votre propre serveur de messagerie, aussi les fonctions activées et un nombre approximatif de comptes. Jamais de messages, d'adresses ni de noms de serveur.",
|
||||
"crash_label": "Envoyer les rapports de plantage",
|
||||
"crash_description": "Les erreurs de l'application partent avec les noms de fichiers du code de l'application et de courts messages nettoyés. Si l'application elle-même plante, un fichier de plantage reste sur cet ordinateur et n'en sort que si vous l'envoyez.",
|
||||
"crash_memory": "Un fichier de plantage est une copie d'une partie de la mémoire de l'application au moment du plantage. Il peut donc contenir des parties de messages ouverts. Bulwark retire les noms d'utilisateur, les adresses et les jetons de connexion qu'il trouve, mais ne peut pas promettre de tout trouver.",
|
||||
"instance_note": "Sur un serveur Bulwark, son administrateur décide séparément de ce que ses pages lui signalent.",
|
||||
"forced_off": "Votre organisation a désactivé cette option.",
|
||||
"url_label": "Destination des fichiers de plantage",
|
||||
"url_description": "L'adresse https qui reçoit un fichier de plantage. Bulwark n'en fournit aucune.",
|
||||
"url_invalid": "Saisissez une adresse https complète.",
|
||||
"url_save": "Enregistrer",
|
||||
"files_label": "Fichiers de plantage sur cet ordinateur",
|
||||
"files_description": "Bulwark garde les plus récents et supprime ceux de plus d'un mois.",
|
||||
"files_empty": "Aucun fichier de plantage.",
|
||||
"file_size": "{size} Ko",
|
||||
"send": "Envoyer le rapport de plantage…",
|
||||
"delete_all": "Tout supprimer",
|
||||
"sent": "Rapport de plantage envoyé.",
|
||||
"send_no_consent": "Activez d'abord les rapports de plantage.",
|
||||
"send_no_url": "Aucune adresse n'est définie, il n'y a donc nulle part où envoyer ce rapport. Saisissez-en une ci-dessus.",
|
||||
"send_failed": "Le rapport de plantage n'a pas pu être envoyé."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6632,5 +6632,30 @@
|
||||
"plugins_off": "הארגון שלך כיבה תוספים במכשיר הזה.",
|
||||
"plugins_allowlist": "הארגון שלך מאפשר במכשיר הזה רק תוספים מסוימים.",
|
||||
"off": "כבוי"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "מידע על שימוש ודוחות קריסה",
|
||||
"section_description": "שניהם כבויים עד שתפעילו אותם, ולכל אחד מתג משלו.",
|
||||
"telemetry_label": "שליחת מידע על שימוש",
|
||||
"telemetry_description": "פעם ביום: גרסת האפליקציה, מערכת ההפעלה ואופן החיבור של האפליקציה. עם שרת דואר משלכם, גם אילו תכונות פעילות ומספר משוער של חשבונות. אף פעם לא דואר, כתובות או שמות שרתים.",
|
||||
"crash_label": "שליחת דוחות קריסה",
|
||||
"crash_description": "שגיאות באפליקציה נשלחות עם שמות קבצים מהקוד של האפליקציה עצמה והודעות קצרות ומנוקות. אם האפליקציה עצמה קורסת, קובץ קריסה נשאר במחשב הזה ויוצא ממנו רק אם תשלחו אותו.",
|
||||
"crash_memory": "קובץ קריסה הוא העתק של חלק מזיכרון האפליקציה ברגע הקריסה, ולכן הוא עשוי להכיל חלקים מהודעות דואר פתוחות. Bulwark מסיר שמות משתמש, כתובות ואסימוני התחברות שהוא מוצא, אבל לא יכול להבטיח שימצא הכול.",
|
||||
"instance_note": "בשרת Bulwark, מנהל השרת מחליט בנפרד מה הדפים מדווחים לו.",
|
||||
"forced_off": "הארגון שלכם כיבה את זה.",
|
||||
"url_label": "לאן קובצי הקריסה הולכים",
|
||||
"url_description": "כתובת ה-https שמקבלת קובץ קריסה. Bulwark לא מגיע עם כתובת.",
|
||||
"url_invalid": "הזינו כתובת https מלאה.",
|
||||
"url_save": "שמירה",
|
||||
"files_label": "קובצי קריסה במחשב הזה",
|
||||
"files_description": "Bulwark שומר את החדשים ביותר ומוחק קבצים בני יותר מחודש.",
|
||||
"files_empty": "אין קובצי קריסה.",
|
||||
"file_size": "{size} ק״ב",
|
||||
"send": "שליחת דוח קריסה…",
|
||||
"delete_all": "מחיקת הכול",
|
||||
"sent": "דוח הקריסה נשלח.",
|
||||
"send_no_consent": "הפעילו קודם את דוחות הקריסה.",
|
||||
"send_no_url": "לא הוגדרה כתובת, ולכן אין לאן לשלוח. הזינו כתובת למעלה.",
|
||||
"send_failed": "לא ניתן היה לשלוח את דוח הקריסה."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "A szervezete ezen az eszközön kikapcsolta a bővítményeket.",
|
||||
"plugins_allowlist": "A szervezete ezen az eszközön csak bizonyos bővítményeket engedélyez.",
|
||||
"off": "Ki"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Használati adatok és összeomlási jelentések",
|
||||
"section_description": "Mindkettő ki van kapcsolva, amíg be nem kapcsolja, és mindegyiknek saját kapcsolója van.",
|
||||
"telemetry_label": "Használati adatok küldése",
|
||||
"telemetry_description": "Naponta egyszer: az alkalmazás verziója, az operációs rendszer és az alkalmazás kapcsolódási módja. Saját levelezőszervernél azt is, mely funkciók vannak bekapcsolva, és a fiókok hozzávetőleges számát. Soha nem küld leveleket, címeket vagy szerverneveket.",
|
||||
"crash_label": "Összeomlási jelentések küldése",
|
||||
"crash_description": "Az alkalmazás hibái az alkalmazás saját kódjának fájlneveivel és rövid, megtisztított üzenetekkel mennek ki. Ha maga az alkalmazás omlik össze, egy összeomlási fájl marad ezen a számítógépen, és csak akkor kerül ki, ha elküldi.",
|
||||
"crash_memory": "Az összeomlási fájl az alkalmazás memóriájának egy részéről készült másolat az összeomlás pillanatából, így megnyitott levelek részeit is tartalmazhatja. A Bulwark eltávolítja a talált felhasználóneveket, címeket és bejelentkezési tokeneket, de nem ígérheti, hogy mindent megtalál.",
|
||||
"instance_note": "Bulwark-szerveren annak rendszergazdája külön dönt arról, mit jelentenek neki az oldalak.",
|
||||
"forced_off": "A szervezete ezt kikapcsolta.",
|
||||
"url_label": "Hová kerülnek az összeomlási fájlok",
|
||||
"url_description": "A https-cím, amely fogadja az összeomlási fájlt. A Bulwark nem ad ilyet.",
|
||||
"url_invalid": "Adjon meg egy teljes https-címet.",
|
||||
"url_save": "Mentés",
|
||||
"files_label": "Összeomlási fájlok ezen a számítógépen",
|
||||
"files_description": "A Bulwark megtartja a legújabbakat, és törli az egy hónapnál régebbieket.",
|
||||
"files_empty": "Nincsenek összeomlási fájlok.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Összeomlási jelentés küldése…",
|
||||
"delete_all": "Összes törlése",
|
||||
"sent": "Összeomlási jelentés elküldve.",
|
||||
"send_no_consent": "Először kapcsolja be az összeomlási jelentéseket.",
|
||||
"send_no_url": "Nincs megadva cím, így nincs hová küldeni. Adjon meg egyet fent.",
|
||||
"send_failed": "Az összeomlási jelentést nem sikerült elküldeni."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "La tua organizzazione ha disattivato i plugin su questo dispositivo.",
|
||||
"plugins_allowlist": "La tua organizzazione consente solo alcuni plugin su questo dispositivo.",
|
||||
"off": "Disattivato"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Dati di utilizzo e segnalazioni di arresto anomalo",
|
||||
"section_description": "Entrambi restano disattivati finché non li attivi, e ognuno ha il proprio interruttore.",
|
||||
"telemetry_label": "Invia dati di utilizzo",
|
||||
"telemetry_description": "Una volta al giorno: la versione dell'app, il tuo sistema operativo e come si connette l'app. Con un tuo server di posta, anche quali funzioni sono attive e un numero approssimativo di account. Mai email, indirizzi o nomi di server.",
|
||||
"crash_label": "Invia segnalazioni di arresto anomalo",
|
||||
"crash_description": "Gli errori dell'app vengono inviati con i nomi dei file del codice dell'app e brevi messaggi ripuliti. Se l'app stessa si arresta, un file di arresto resta su questo computer e ne esce solo se lo invii.",
|
||||
"crash_memory": "Un file di arresto è una copia di parte della memoria dell'app al momento dell'arresto, quindi può contenere parti di email aperte. Bulwark rimuove i nomi utente, gli indirizzi e i token di accesso che trova, ma non può promettere di trovare tutto.",
|
||||
"instance_note": "Su un server Bulwark, il suo amministratore decide separatamente cosa le sue pagine gli segnalano.",
|
||||
"forced_off": "La tua organizzazione ha disattivato questa opzione.",
|
||||
"url_label": "Destinazione dei file di arresto",
|
||||
"url_description": "L'indirizzo https che riceve un file di arresto. Bulwark non ne include nessuno.",
|
||||
"url_invalid": "Inserisci un indirizzo https completo.",
|
||||
"url_save": "Salva",
|
||||
"files_label": "File di arresto su questo computer",
|
||||
"files_description": "Bulwark conserva i più recenti ed elimina quelli più vecchi di un mese.",
|
||||
"files_empty": "Nessun file di arresto.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Invia segnalazione di arresto…",
|
||||
"delete_all": "Elimina tutti",
|
||||
"sent": "Segnalazione di arresto inviata.",
|
||||
"send_no_consent": "Attiva prima le segnalazioni di arresto anomalo.",
|
||||
"send_no_url": "Non è impostato alcun indirizzo, quindi non c'è dove inviarla. Inseriscine uno qui sopra.",
|
||||
"send_failed": "Impossibile inviare la segnalazione di arresto."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "組織はこのデバイスのプラグインをオフにしています。",
|
||||
"plugins_allowlist": "組織はこのデバイスで特定のプラグインのみを許可しています。",
|
||||
"off": "オフ"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "利用情報とクラッシュレポート",
|
||||
"section_description": "どちらもオンにするまでオフのままで、それぞれに専用のスイッチがあります。",
|
||||
"telemetry_label": "利用情報を送信",
|
||||
"telemetry_description": "1日に1回、アプリのバージョン、OS、アプリの接続方法を送信します。自分のメールサーバーを使う場合は、有効な機能とおおよそのアカウント数も送信します。メール、アドレス、サーバー名は送信しません。",
|
||||
"crash_label": "クラッシュレポートを送信",
|
||||
"crash_description": "アプリのエラーは、アプリ自身のコードのファイル名と、整理された短いメッセージとともに送信されます。アプリ自体がクラッシュした場合、クラッシュファイルはこのコンピューターに残り、あなたが送信したときだけ外に出ます。",
|
||||
"crash_memory": "クラッシュファイルはクラッシュ時のアプリのメモリの一部のコピーで、開いていたメールの一部が含まれることがあります。Bulwark は見つけたユーザー名、アドレス、サインイントークンを削除しますが、すべてを見つけられるとは限りません。",
|
||||
"instance_note": "Bulwark サーバーでは、ページがサーバーに何を報告するかはそのサーバーの管理者が別途決めます。",
|
||||
"forced_off": "組織によってオフにされています。",
|
||||
"url_label": "クラッシュファイルの送信先",
|
||||
"url_description": "クラッシュファイルを受け取る https アドレスです。Bulwark には用意されていません。",
|
||||
"url_invalid": "完全な https アドレスを入力してください。",
|
||||
"url_save": "保存",
|
||||
"files_label": "このコンピューター上のクラッシュファイル",
|
||||
"files_description": "Bulwark は新しいものを保持し、1か月より古いものを削除します。",
|
||||
"files_empty": "クラッシュファイルはありません。",
|
||||
"file_size": "{size} KB",
|
||||
"send": "クラッシュレポートを送信…",
|
||||
"delete_all": "すべて削除",
|
||||
"sent": "クラッシュレポートを送信しました。",
|
||||
"send_no_consent": "先にクラッシュレポートをオンにしてください。",
|
||||
"send_no_url": "アドレスが設定されていないため、送信先がありません。上で入力してください。",
|
||||
"send_failed": "クラッシュレポートを送信できませんでした。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "조직에서 이 기기의 플러그인을 껐습니다.",
|
||||
"plugins_allowlist": "조직에서 이 기기에 특정 플러그인만 허용합니다.",
|
||||
"off": "끔"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "사용 정보 및 충돌 보고서",
|
||||
"section_description": "둘 다 켤 때까지 꺼져 있으며, 각각 별도의 스위치가 있습니다.",
|
||||
"telemetry_label": "사용 정보 보내기",
|
||||
"telemetry_description": "하루에 한 번: 앱 버전, 운영 체제, 앱의 연결 방식을 보냅니다. 자체 메일 서버를 쓰면 켜진 기능과 대략적인 계정 수도 보냅니다. 메일, 주소, 서버 이름은 절대 보내지 않습니다.",
|
||||
"crash_label": "충돌 보고서 보내기",
|
||||
"crash_description": "앱 오류는 앱 자체 코드의 파일 이름과 정리된 짧은 메시지와 함께 전송됩니다. 앱 자체가 충돌하면 충돌 파일이 이 컴퓨터에 남고, 직접 보낼 때만 밖으로 나갑니다.",
|
||||
"crash_memory": "충돌 파일은 충돌 당시 앱 메모리 일부의 복사본이므로 열려 있던 메일의 일부가 포함될 수 있습니다. Bulwark는 찾아낸 사용자 이름, 주소, 로그인 토큰을 제거하지만 모두 찾는다고 약속할 수는 없습니다.",
|
||||
"instance_note": "Bulwark 서버에서는 페이지가 서버에 무엇을 보고할지 해당 서버의 관리자가 별도로 정합니다.",
|
||||
"forced_off": "조직에서 이 기능을 껐습니다.",
|
||||
"url_label": "충돌 파일을 보낼 곳",
|
||||
"url_description": "충돌 파일을 받는 https 주소입니다. Bulwark에는 기본 주소가 없습니다.",
|
||||
"url_invalid": "전체 https 주소를 입력하세요.",
|
||||
"url_save": "저장",
|
||||
"files_label": "이 컴퓨터의 충돌 파일",
|
||||
"files_description": "Bulwark는 최신 파일을 보관하고 한 달이 지난 파일은 삭제합니다.",
|
||||
"files_empty": "충돌 파일이 없습니다.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "충돌 보고서 보내기…",
|
||||
"delete_all": "모두 삭제",
|
||||
"sent": "충돌 보고서를 보냈습니다.",
|
||||
"send_no_consent": "먼저 충돌 보고서를 켜세요.",
|
||||
"send_no_url": "주소가 설정되지 않아 보낼 곳이 없습니다. 위에 입력하세요.",
|
||||
"send_failed": "충돌 보고서를 보낼 수 없습니다."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Jūsu organizācija šajā ierīcē ir izslēgusi spraudņus.",
|
||||
"plugins_allowlist": "Jūsu organizācija šajā ierīcē atļauj tikai noteiktus spraudņus.",
|
||||
"off": "Izslēgts"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Lietošanas dati un avāriju atskaites",
|
||||
"section_description": "Abi ir izslēgti, līdz tos ieslēdzat, un katram ir savs slēdzis.",
|
||||
"telemetry_label": "Sūtīt lietošanas datus",
|
||||
"telemetry_description": "Reizi dienā: lietotnes versija, jūsu operētājsistēma un lietotnes savienojuma veids. Ar savu pasta serveri arī tas, kuras funkcijas ir ieslēgtas, un aptuvens kontu skaits. Nekad vēstules, adreses vai serveru nosaukumi.",
|
||||
"crash_label": "Sūtīt avāriju atskaites",
|
||||
"crash_description": "Lietotnes kļūdas tiek sūtītas ar failu nosaukumiem no pašas lietotnes koda un īsiem, attīrītiem ziņojumiem. Ja avarē pati lietotne, avārijas fails paliek šajā datorā un to atstāj tikai tad, ja to nosūtāt.",
|
||||
"crash_memory": "Avārijas fails ir daļas lietotnes atmiņas kopija avārijas brīdī, tāpēc tajā var būt atvērtu vēstuļu daļas. Bulwark noņem atrastos lietotājvārdus, adreses un pierakstīšanās marķierus, bet nevar apsolīt atrast visu.",
|
||||
"instance_note": "Bulwark serverī tā administrators atsevišķi izlemj, ko lapas tam ziņo.",
|
||||
"forced_off": "Jūsu organizācija to ir izslēgusi.",
|
||||
"url_label": "Kurp nonāk avārijas faili",
|
||||
"url_description": "Https adrese, kas saņem avārijas failu. Bulwark tādu nenodrošina.",
|
||||
"url_invalid": "Ievadiet pilnu https adresi.",
|
||||
"url_save": "Saglabāt",
|
||||
"files_label": "Avārijas faili šajā datorā",
|
||||
"files_description": "Bulwark patur jaunākos un dzēš tos, kas vecāki par mēnesi.",
|
||||
"files_empty": "Nav avārijas failu.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Sūtīt avārijas atskaiti…",
|
||||
"delete_all": "Dzēst visus",
|
||||
"sent": "Avārijas atskaite nosūtīta.",
|
||||
"send_no_consent": "Vispirms ieslēdziet avāriju atskaites.",
|
||||
"send_no_url": "Adrese nav iestatīta, tāpēc to nav kur sūtīt. Ievadiet to augstāk.",
|
||||
"send_failed": "Avārijas atskaiti neizdevās nosūtīt."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Танай байгууллага энэ төхөөрөмж дээр залгаасуудыг унтраасан.",
|
||||
"plugins_allowlist": "Танай байгууллага энэ төхөөрөмж дээр зөвхөн тодорхой залгаасуудыг зөвшөөрдөг.",
|
||||
"off": "Унтраалттай"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Хэрэглээний мэдээлэл ба гэмтлийн тайлан",
|
||||
"section_description": "Хоёулаа таныг асаах хүртэл унтраалттай бөгөөд тус бүр өөрийн унтраалгатай.",
|
||||
"telemetry_label": "Хэрэглээний мэдээлэл илгээх",
|
||||
"telemetry_description": "Өдөрт нэг удаа: аппын хувилбар, таны үйлдлийн систем, апп хэрхэн холбогддог. Өөрийн шуудангийн сервертэй бол мөн ямар боломжууд асаалттай, бүртгэлийн ойролцоо тоо. Захидал, хаяг, серверийн нэрийг хэзээ ч илгээхгүй.",
|
||||
"crash_label": "Гэмтлийн тайлан илгээх",
|
||||
"crash_description": "Аппын алдаанууд аппын өөрийн кодын файлын нэрс болон богино, цэвэрлэсэн мессежтэй хамт илгээгдэнэ. Апп өөрөө гэмтвэл гэмтлийн файл энэ компьютерт үлдэх бөгөөд та илгээсэн үед л гарна.",
|
||||
"crash_memory": "Гэмтлийн файл нь гэмтэх үеийн аппын санах ойн хэсгийн хуулбар тул нээлттэй захидлын хэсгүүдийг агуулж болно. Bulwark олсон хэрэглэгчийн нэр, хаяг, нэвтрэх токенуудыг устгадаг ч бүгдийг олно гэж амлаж чадахгүй.",
|
||||
"instance_note": "Bulwark сервер дээр хуудсууд түүнд юу мэдээлэхийг админ нь тусад нь шийддэг.",
|
||||
"forced_off": "Танай байгууллага үүнийг унтраасан.",
|
||||
"url_label": "Гэмтлийн файлууд хаашаа очих",
|
||||
"url_description": "Гэмтлийн файлыг хүлээн авах https хаяг. Bulwark-д ийм хаяг байхгүй.",
|
||||
"url_invalid": "Бүтэн https хаяг оруулна уу.",
|
||||
"url_save": "Хадгалах",
|
||||
"files_label": "Энэ компьютер дээрх гэмтлийн файлууд",
|
||||
"files_description": "Bulwark хамгийн сүүлийнхийг хадгалж, сараас хуучныг устгадаг.",
|
||||
"files_empty": "Гэмтлийн файл алга.",
|
||||
"file_size": "{size} КБ",
|
||||
"send": "Гэмтлийн тайлан илгээх…",
|
||||
"delete_all": "Бүгдийг устгах",
|
||||
"sent": "Гэмтлийн тайлан илгээгдлээ.",
|
||||
"send_no_consent": "Эхлээд гэмтлийн тайланг асаана уу.",
|
||||
"send_no_url": "Хаяг тохируулаагүй тул илгээх газаргүй. Дээр нэгийг оруулна уу.",
|
||||
"send_failed": "Гэмтлийн тайланг илгээж чадсангүй."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Organisasjonen din har slått av programtillegg på denne enheten.",
|
||||
"plugins_allowlist": "Organisasjonen din tillater bare bestemte programtillegg på denne enheten.",
|
||||
"off": "Av"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Bruksdata og krasjrapporter",
|
||||
"section_description": "Begge er av til du slår dem på, og hver har sin egen bryter.",
|
||||
"telemetry_label": "Send bruksdata",
|
||||
"telemetry_description": "Én gang om dagen: appversjonen, operativsystemet ditt og hvordan appen kobler til. Med egen e-postserver også hvilke funksjoner som er på og et omtrentlig antall kontoer. Aldri e-post, adresser eller servernavn.",
|
||||
"crash_label": "Send krasjrapporter",
|
||||
"crash_description": "Feil i appen sendes med filnavn fra appens egen kode og korte, rensede meldinger. Hvis selve appen krasjer, blir en krasjfil liggende på denne datamaskinen og forlater den bare hvis du sender den.",
|
||||
"crash_memory": "En krasjfil er en kopi av en del av appens minne da den krasjet, så den kan inneholde deler av åpen e-post. Bulwark fjerner brukernavn, adresser og påloggingstokener det finner, men kan ikke love å finne alt.",
|
||||
"instance_note": "På en Bulwark-server bestemmer administratoren separat hva sidene rapporterer til den.",
|
||||
"forced_off": "Organisasjonen din har slått av dette.",
|
||||
"url_label": "Hvor krasjfiler sendes",
|
||||
"url_description": "Https-adressen som mottar en krasjfil. Bulwark har ingen med.",
|
||||
"url_invalid": "Skriv inn en fullstendig https-adresse.",
|
||||
"url_save": "Lagre",
|
||||
"files_label": "Krasjfiler på denne datamaskinen",
|
||||
"files_description": "Bulwark beholder de nyeste og sletter dem som er eldre enn en måned.",
|
||||
"files_empty": "Ingen krasjfiler.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Send krasjrapport…",
|
||||
"delete_all": "Slett alle",
|
||||
"sent": "Krasjrapport sendt.",
|
||||
"send_no_consent": "Slå på krasjrapporter først.",
|
||||
"send_no_url": "Ingen adresse er angitt, så den kan ikke sendes noe sted. Skriv inn en ovenfor.",
|
||||
"send_failed": "Krasjrapporten kunne ikke sendes."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Je organisatie heeft plug-ins op dit apparaat uitgeschakeld.",
|
||||
"plugins_allowlist": "Je organisatie staat op dit apparaat alleen bepaalde plug-ins toe.",
|
||||
"off": "Uit"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Gebruiksgegevens en crashrapporten",
|
||||
"section_description": "Beide staan uit totdat je ze aanzet, en elk heeft een eigen schakelaar.",
|
||||
"telemetry_label": "Gebruiksgegevens verzenden",
|
||||
"telemetry_description": "Eén keer per dag: de appversie, je besturingssysteem en hoe de app verbinding maakt. Met je eigen mailserver ook welke functies aan staan en een ruw aantal accounts. Nooit e-mail, adressen of servernamen.",
|
||||
"crash_label": "Crashrapporten verzenden",
|
||||
"crash_description": "Fouten in de app gaan mee met bestandsnamen uit de eigen code van de app en korte, opgeschoonde meldingen. Als de app zelf crasht, blijft er een crashbestand op deze computer staan dat alleen weggaat als jij het verzendt.",
|
||||
"crash_memory": "Een crashbestand is een kopie van een deel van het geheugen van de app op het moment van de crash en kan dus delen van geopende e-mail bevatten. Bulwark verwijdert gebruikersnamen, adressen en aanmeldtokens die het vindt, maar kan niet beloven alles te vinden.",
|
||||
"instance_note": "Op een Bulwark-server bepaalt de beheerder daarvan apart wat de pagina's aan die server melden.",
|
||||
"forced_off": "Je organisatie heeft dit uitgezet.",
|
||||
"url_label": "Bestemming van crashbestanden",
|
||||
"url_description": "Het https-adres dat een crashbestand ontvangt. Bulwark heeft er zelf geen.",
|
||||
"url_invalid": "Vul een volledig https-adres in.",
|
||||
"url_save": "Opslaan",
|
||||
"files_label": "Crashbestanden op deze computer",
|
||||
"files_description": "Bulwark bewaart de nieuwste en verwijdert bestanden die ouder zijn dan een maand.",
|
||||
"files_empty": "Geen crashbestanden.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Crashrapport verzenden…",
|
||||
"delete_all": "Alles verwijderen",
|
||||
"sent": "Crashrapport verzonden.",
|
||||
"send_no_consent": "Zet eerst crashrapporten aan.",
|
||||
"send_no_url": "Er is geen adres ingesteld, dus dit kan nergens heen. Vul hierboven een adres in.",
|
||||
"send_failed": "Het crashrapport kon niet worden verzonden."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Twoja organizacja wyłączyła wtyczki na tym urządzeniu.",
|
||||
"plugins_allowlist": "Twoja organizacja zezwala na tym urządzeniu tylko na wybrane wtyczki.",
|
||||
"off": "Wyłączone"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Dane o użyciu i raporty o awariach",
|
||||
"section_description": "Oba są wyłączone, dopóki ich nie włączysz, a każdy ma własny przełącznik.",
|
||||
"telemetry_label": "Wysyłaj dane o użyciu",
|
||||
"telemetry_description": "Raz dziennie: wersja aplikacji, system operacyjny i sposób łączenia się aplikacji. Przy własnym serwerze poczty także włączone funkcje i przybliżona liczba kont. Nigdy poczta, adresy ani nazwy serwerów.",
|
||||
"crash_label": "Wysyłaj raporty o awariach",
|
||||
"crash_description": "Błędy aplikacji są wysyłane z nazwami plików z kodu samej aplikacji i krótkimi, oczyszczonymi komunikatami. Jeśli sama aplikacja ulegnie awarii, plik awarii zostaje na tym komputerze i opuszcza go tylko wtedy, gdy go wyślesz.",
|
||||
"crash_memory": "Plik awarii to kopia części pamięci aplikacji z chwili awarii, więc może zawierać fragmenty otwartej poczty. Bulwark usuwa nazwy użytkowników, adresy i tokeny logowania, które znajdzie, ale nie może obiecać, że znajdzie wszystko.",
|
||||
"instance_note": "Na serwerze Bulwark jego administrator osobno decyduje, co strony mu zgłaszają.",
|
||||
"forced_off": "Twoja organizacja to wyłączyła.",
|
||||
"url_label": "Dokąd trafiają pliki awarii",
|
||||
"url_description": "Adres https, który odbiera plik awarii. Bulwark nie ma własnego.",
|
||||
"url_invalid": "Wpisz pełny adres https.",
|
||||
"url_save": "Zapisz",
|
||||
"files_label": "Pliki awarii na tym komputerze",
|
||||
"files_description": "Bulwark zachowuje kilka najnowszych i usuwa starsze niż miesiąc.",
|
||||
"files_empty": "Brak plików awarii.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Wyślij raport o awarii…",
|
||||
"delete_all": "Usuń wszystkie",
|
||||
"sent": "Raport o awarii wysłany.",
|
||||
"send_no_consent": "Najpierw włącz raporty o awariach.",
|
||||
"send_no_url": "Nie ustawiono adresu, więc nie ma dokąd tego wysłać. Wpisz go powyżej.",
|
||||
"send_failed": "Nie udało się wysłać raportu o awarii."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "A sua organização desativou os plugins neste dispositivo.",
|
||||
"plugins_allowlist": "A sua organização só permite determinados plugins neste dispositivo.",
|
||||
"off": "Desativado"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Dados de utilização e relatórios de falhas",
|
||||
"section_description": "Ambos ficam desligados até os ligar, e cada um tem o seu próprio interruptor.",
|
||||
"telemetry_label": "Enviar dados de utilização",
|
||||
"telemetry_description": "Uma vez por dia: a versão da aplicação, o seu sistema operativo e a forma como a aplicação se liga. Com o seu próprio servidor de correio, também que funções estão ativas e um número aproximado de contas. Nunca mensagens, endereços ou nomes de servidor.",
|
||||
"crash_label": "Enviar relatórios de falhas",
|
||||
"crash_description": "Os erros da aplicação são enviados com nomes de ficheiros do próprio código da aplicação e mensagens curtas e limpas. Se a própria aplicação falhar, um ficheiro de falha fica neste computador e só sai se o enviar.",
|
||||
"crash_memory": "Um ficheiro de falha é uma cópia de parte da memória da aplicação no momento da falha, por isso pode conter partes de mensagens abertas. O Bulwark remove os nomes de utilizador, endereços e tokens de início de sessão que encontra, mas não pode prometer encontrar tudo.",
|
||||
"instance_note": "Num servidor Bulwark, o seu administrador decide separadamente o que as páginas lhe comunicam.",
|
||||
"forced_off": "A sua organização desligou esta opção.",
|
||||
"url_label": "Destino dos ficheiros de falha",
|
||||
"url_description": "O endereço https que recebe um ficheiro de falha. O Bulwark não inclui nenhum.",
|
||||
"url_invalid": "Introduza um endereço https completo.",
|
||||
"url_save": "Guardar",
|
||||
"files_label": "Ficheiros de falha neste computador",
|
||||
"files_description": "O Bulwark mantém os mais recentes e elimina os que têm mais de um mês.",
|
||||
"files_empty": "Sem ficheiros de falha.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Enviar relatório de falha…",
|
||||
"delete_all": "Eliminar tudo",
|
||||
"sent": "Relatório de falha enviado.",
|
||||
"send_no_consent": "Ligue primeiro os relatórios de falhas.",
|
||||
"send_no_url": "Não há nenhum endereço definido, por isso não há para onde enviar. Introduza um acima.",
|
||||
"send_failed": "Não foi possível enviar o relatório de falha."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Organizația ta a dezactivat pluginurile pe acest dispozitiv.",
|
||||
"plugins_allowlist": "Organizația ta permite doar anumite pluginuri pe acest dispozitiv.",
|
||||
"off": "Dezactivat"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Date de utilizare și rapoarte de blocare",
|
||||
"section_description": "Ambele sunt oprite până le porniți și fiecare are propriul comutator.",
|
||||
"telemetry_label": "Trimite date de utilizare",
|
||||
"telemetry_description": "O dată pe zi: versiunea aplicației, sistemul de operare și modul în care se conectează aplicația. Cu propriul server de e-mail, și ce funcții sunt pornite și un număr aproximativ de conturi. Niciodată e-mailuri, adrese sau nume de servere.",
|
||||
"crash_label": "Trimite rapoarte de blocare",
|
||||
"crash_description": "Erorile din aplicație pleacă cu nume de fișiere din codul aplicației și mesaje scurte, curățate. Dacă aplicația însăși se blochează, un fișier de blocare rămâne pe acest computer și pleacă doar dacă îl trimiteți.",
|
||||
"crash_memory": "Un fișier de blocare este o copie a unei părți din memoria aplicației din momentul blocării, deci poate conține părți din e-mailuri deschise. Bulwark elimină numele de utilizator, adresele și tokenurile de autentificare pe care le găsește, dar nu poate promite că găsește totul.",
|
||||
"instance_note": "Pe un server Bulwark, administratorul acestuia decide separat ce îi raportează paginile.",
|
||||
"forced_off": "Organizația dvs. a oprit această opțiune.",
|
||||
"url_label": "Unde ajung fișierele de blocare",
|
||||
"url_description": "Adresa https care primește un fișier de blocare. Bulwark nu vine cu una.",
|
||||
"url_invalid": "Introduceți o adresă https completă.",
|
||||
"url_save": "Salvează",
|
||||
"files_label": "Fișiere de blocare pe acest computer",
|
||||
"files_description": "Bulwark le păstrează pe cele mai noi și le șterge pe cele mai vechi de o lună.",
|
||||
"files_empty": "Nu există fișiere de blocare.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Trimite raportul de blocare…",
|
||||
"delete_all": "Șterge tot",
|
||||
"sent": "Raport de blocare trimis.",
|
||||
"send_no_consent": "Porniți mai întâi rapoartele de blocare.",
|
||||
"send_no_url": "Nu este setată nicio adresă, deci nu există unde să fie trimis. Introduceți una mai sus.",
|
||||
"send_failed": "Raportul de blocare nu a putut fi trimis."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Ваша организация отключила плагины на этом устройстве.",
|
||||
"plugins_allowlist": "Ваша организация разрешает на этом устройстве только некоторые плагины.",
|
||||
"off": "Выкл."
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Данные об использовании и отчёты о сбоях",
|
||||
"section_description": "Оба параметра выключены, пока вы их не включите, и у каждого свой переключатель.",
|
||||
"telemetry_label": "Отправлять данные об использовании",
|
||||
"telemetry_description": "Раз в день: версия приложения, ваша операционная система и способ подключения приложения. При собственном почтовом сервере также включённые функции и примерное число учётных записей. Никогда письма, адреса или имена серверов.",
|
||||
"crash_label": "Отправлять отчёты о сбоях",
|
||||
"crash_description": "Ошибки в приложении отправляются с именами файлов из кода самого приложения и короткими очищенными сообщениями. Если сбой происходит в самом приложении, файл сбоя остаётся на этом компьютере и покидает его, только если вы его отправите.",
|
||||
"crash_memory": "Файл сбоя — это копия части памяти приложения в момент сбоя, поэтому он может содержать части открытых писем. Bulwark удаляет найденные имена пользователей, адреса и токены входа, но не может обещать, что найдёт всё.",
|
||||
"instance_note": "На сервере Bulwark его администратор отдельно решает, что страницы сообщают серверу.",
|
||||
"forced_off": "Ваша организация отключила это.",
|
||||
"url_label": "Куда отправляются файлы сбоев",
|
||||
"url_description": "Адрес https, который принимает файл сбоя. В Bulwark его нет.",
|
||||
"url_invalid": "Введите полный адрес https.",
|
||||
"url_save": "Сохранить",
|
||||
"files_label": "Файлы сбоев на этом компьютере",
|
||||
"files_description": "Bulwark хранит самые новые и удаляет те, что старше месяца.",
|
||||
"files_empty": "Файлов сбоев нет.",
|
||||
"file_size": "{size} КБ",
|
||||
"send": "Отправить отчёт о сбое…",
|
||||
"delete_all": "Удалить все",
|
||||
"sent": "Отчёт о сбое отправлен.",
|
||||
"send_no_consent": "Сначала включите отчёты о сбоях.",
|
||||
"send_no_url": "Адрес не задан, поэтому отправлять некуда. Введите его выше.",
|
||||
"send_failed": "Не удалось отправить отчёт о сбое."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Vaša organizácia na tomto zariadení vypla pluginy.",
|
||||
"plugins_allowlist": "Vaša organizácia na tomto zariadení povoľuje len niektoré pluginy.",
|
||||
"off": "Vypnuté"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Údaje o používaní a hlásenia o zlyhaniach",
|
||||
"section_description": "Oboje je vypnuté, kým to nezapnete, a každé má vlastný prepínač.",
|
||||
"telemetry_label": "Odosielať údaje o používaní",
|
||||
"telemetry_description": "Raz denne: verzia aplikácie, váš operačný systém a spôsob pripojenia aplikácie. Pri vlastnom poštovom serveri aj zapnuté funkcie a približný počet účtov. Nikdy pošta, adresy ani názvy serverov.",
|
||||
"crash_label": "Odosielať hlásenia o zlyhaniach",
|
||||
"crash_description": "Chyby v aplikácii sa odosielajú s názvami súborov z kódu aplikácie a krátkymi, očistenými správami. Keď zlyhá samotná aplikácia, súbor o zlyhaní zostane v tomto počítači a odíde len vtedy, keď ho odošlete.",
|
||||
"crash_memory": "Súbor o zlyhaní je kópia časti pamäte aplikácie v okamihu zlyhania, takže môže obsahovať časti otvorenej pošty. Bulwark odstráni používateľské mená, adresy a prihlasovacie tokeny, ktoré nájde, ale nemôže sľúbiť, že nájde všetko.",
|
||||
"instance_note": "Na serveri Bulwark rozhoduje jeho správca samostatne o tom, čo mu stránky hlásia.",
|
||||
"forced_off": "Vaša organizácia to vypla.",
|
||||
"url_label": "Kam idú súbory o zlyhaní",
|
||||
"url_description": "Adresa https, ktorá prijíma súbor o zlyhaní. Bulwark žiadnu nemá.",
|
||||
"url_invalid": "Zadajte úplnú adresu https.",
|
||||
"url_save": "Uložiť",
|
||||
"files_label": "Súbory o zlyhaní v tomto počítači",
|
||||
"files_description": "Bulwark ponechá najnovšie a odstráni tie staršie ako mesiac.",
|
||||
"files_empty": "Žiadne súbory o zlyhaní.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Odoslať hlásenie o zlyhaní…",
|
||||
"delete_all": "Odstrániť všetko",
|
||||
"sent": "Hlásenie o zlyhaní odoslané.",
|
||||
"send_no_consent": "Najprv zapnite hlásenia o zlyhaniach.",
|
||||
"send_no_url": "Nie je nastavená žiadna adresa, takže to nie je kam odoslať. Zadajte ju vyššie.",
|
||||
"send_failed": "Hlásenie o zlyhaní sa nepodarilo odoslať."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6639,5 +6639,30 @@
|
||||
"plugins_off": "องค์กรของคุณปิดปลั๊กอินบนอุปกรณ์นี้",
|
||||
"plugins_allowlist": "องค์กรของคุณอนุญาตเฉพาะปลั๊กอินบางตัวบนอุปกรณ์นี้",
|
||||
"off": "ปิด"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "ข้อมูลการใช้งานและรายงานข้อขัดข้อง",
|
||||
"section_description": "ทั้งสองอย่างปิดอยู่จนกว่าคุณจะเปิด และแต่ละอย่างมีสวิตช์ของตัวเอง",
|
||||
"telemetry_label": "ส่งข้อมูลการใช้งาน",
|
||||
"telemetry_description": "วันละครั้ง: เวอร์ชันแอป ระบบปฏิบัติการของคุณ และวิธีที่แอปเชื่อมต่อ หากใช้เซิร์ฟเวอร์อีเมลของคุณเอง จะส่งฟีเจอร์ที่เปิดอยู่และจำนวนบัญชีโดยประมาณด้วย ไม่ส่งอีเมล ที่อยู่ หรือชื่อเซิร์ฟเวอร์เด็ดขาด",
|
||||
"crash_label": "ส่งรายงานข้อขัดข้อง",
|
||||
"crash_description": "ข้อผิดพลาดในแอปจะถูกส่งพร้อมชื่อไฟล์จากโค้ดของแอปเองและข้อความสั้นๆ ที่ทำความสะอาดแล้ว หากตัวแอปเองขัดข้อง ไฟล์ข้อขัดข้องจะอยู่บนคอมพิวเตอร์เครื่องนี้และออกไปเมื่อคุณส่งเท่านั้น",
|
||||
"crash_memory": "ไฟล์ข้อขัดข้องคือสำเนาส่วนหนึ่งของหน่วยความจำแอปในขณะที่ขัดข้อง จึงอาจมีบางส่วนของอีเมลที่เปิดอยู่ Bulwark จะลบชื่อผู้ใช้ ที่อยู่ และโทเค็นการลงชื่อเข้าใช้ที่พบ แต่ไม่รับประกันว่าจะพบทั้งหมด",
|
||||
"instance_note": "บนเซิร์ฟเวอร์ Bulwark ผู้ดูแลเซิร์ฟเวอร์นั้นจะตัดสินใจแยกต่างหากว่าหน้าเว็บจะรายงานอะไรให้เซิร์ฟเวอร์",
|
||||
"forced_off": "องค์กรของคุณปิดสิ่งนี้แล้ว",
|
||||
"url_label": "ปลายทางของไฟล์ข้อขัดข้อง",
|
||||
"url_description": "ที่อยู่ https ที่รับไฟล์ข้อขัดข้อง Bulwark ไม่มีที่อยู่ให้มาด้วย",
|
||||
"url_invalid": "ป้อนที่อยู่ https แบบเต็ม",
|
||||
"url_save": "บันทึก",
|
||||
"files_label": "ไฟล์ข้อขัดข้องบนคอมพิวเตอร์เครื่องนี้",
|
||||
"files_description": "Bulwark เก็บไฟล์ล่าสุดไว้และลบไฟล์ที่เก่ากว่าหนึ่งเดือน",
|
||||
"files_empty": "ไม่มีไฟล์ข้อขัดข้อง",
|
||||
"file_size": "{size} KB",
|
||||
"send": "ส่งรายงานข้อขัดข้อง…",
|
||||
"delete_all": "ลบทั้งหมด",
|
||||
"sent": "ส่งรายงานข้อขัดข้องแล้ว",
|
||||
"send_no_consent": "เปิดรายงานข้อขัดข้องก่อน",
|
||||
"send_no_url": "ไม่ได้ตั้งที่อยู่ จึงไม่มีปลายทางให้ส่ง ป้อนที่อยู่ด้านบน",
|
||||
"send_failed": "ไม่สามารถส่งรายงานข้อขัดข้องได้"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Kuruluşunuz bu cihazda eklentileri kapattı.",
|
||||
"plugins_allowlist": "Kuruluşunuz bu cihazda yalnızca belirli eklentilere izin veriyor.",
|
||||
"off": "Kapalı"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Kullanım bilgileri ve kilitlenme raporları",
|
||||
"section_description": "İkisi de siz açana kadar kapalıdır ve her birinin kendi anahtarı vardır.",
|
||||
"telemetry_label": "Kullanım bilgilerini gönder",
|
||||
"telemetry_description": "Günde bir kez: uygulama sürümü, işletim sisteminiz ve uygulamanın bağlanma şekli. Kendi posta sunucunuzda ayrıca hangi özelliklerin açık olduğu ve yaklaşık hesap sayısı. Asla e-posta, adres veya sunucu adı gönderilmez.",
|
||||
"crash_label": "Kilitlenme raporlarını gönder",
|
||||
"crash_description": "Uygulamadaki hatalar, uygulamanın kendi kodundaki dosya adları ve kısa, temizlenmiş iletilerle gönderilir. Uygulamanın kendisi kilitlenirse bir kilitlenme dosyası bu bilgisayarda kalır ve yalnızca siz gönderirseniz çıkar.",
|
||||
"crash_memory": "Kilitlenme dosyası, kilitlenme anında uygulama belleğinin bir bölümünün kopyasıdır, bu yüzden açık e-postaların bölümlerini içerebilir. Bulwark bulduğu kullanıcı adlarını, adresleri ve oturum açma belirteçlerini kaldırır ancak her şeyi bulacağına söz veremez.",
|
||||
"instance_note": "Bir Bulwark sunucusunda, sayfaların ona ne bildireceğine yöneticisi ayrıca karar verir.",
|
||||
"forced_off": "Kuruluşunuz bunu kapattı.",
|
||||
"url_label": "Kilitlenme dosyalarının gideceği yer",
|
||||
"url_description": "Kilitlenme dosyasını alan https adresi. Bulwark bir adresle gelmez.",
|
||||
"url_invalid": "Tam bir https adresi girin.",
|
||||
"url_save": "Kaydet",
|
||||
"files_label": "Bu bilgisayardaki kilitlenme dosyaları",
|
||||
"files_description": "Bulwark en yenileri saklar ve bir aydan eski olanları siler.",
|
||||
"files_empty": "Kilitlenme dosyası yok.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Kilitlenme raporunu gönder…",
|
||||
"delete_all": "Tümünü sil",
|
||||
"sent": "Kilitlenme raporu gönderildi.",
|
||||
"send_no_consent": "Önce kilitlenme raporlarını açın.",
|
||||
"send_no_url": "Adres ayarlanmadı, bu yüzden gönderilecek yer yok. Yukarıya bir adres girin.",
|
||||
"send_failed": "Kilitlenme raporu gönderilemedi."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "Ваша організація вимкнула плагіни на цьому пристрої.",
|
||||
"plugins_allowlist": "Ваша організація дозволяє на цьому пристрої лише деякі плагіни.",
|
||||
"off": "Вимк."
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Дані про використання та звіти про збої",
|
||||
"section_description": "Обидва вимкнені, доки ви їх не ввімкнете, і кожен має власний перемикач.",
|
||||
"telemetry_label": "Надсилати дані про використання",
|
||||
"telemetry_description": "Раз на день: версія застосунку, ваша операційна система та спосіб підключення застосунку. З власним поштовим сервером також увімкнені функції та приблизна кількість облікових записів. Ніколи листи, адреси чи назви серверів.",
|
||||
"crash_label": "Надсилати звіти про збої",
|
||||
"crash_description": "Помилки в застосунку надсилаються з назвами файлів із коду самого застосунку та короткими очищеними повідомленнями. Якщо збій стається в самому застосунку, файл збою залишається на цьому комп'ютері й покидає його, лише якщо ви його надішлете.",
|
||||
"crash_memory": "Файл збою — це копія частини пам'яті застосунку в момент збою, тому він може містити частини відкритих листів. Bulwark вилучає знайдені імена користувачів, адреси й токени входу, але не може обіцяти, що знайде все.",
|
||||
"instance_note": "На сервері Bulwark його адміністратор окремо вирішує, що сторінки йому повідомляють.",
|
||||
"forced_off": "Ваша організація вимкнула це.",
|
||||
"url_label": "Куди надходять файли збоїв",
|
||||
"url_description": "Адреса https, яка приймає файл збою. У Bulwark її немає.",
|
||||
"url_invalid": "Введіть повну адресу https.",
|
||||
"url_save": "Зберегти",
|
||||
"files_label": "Файли збоїв на цьому комп'ютері",
|
||||
"files_description": "Bulwark зберігає найновіші та видаляє ті, що старші за місяць.",
|
||||
"files_empty": "Файлів збоїв немає.",
|
||||
"file_size": "{size} КБ",
|
||||
"send": "Надіслати звіт про збій…",
|
||||
"delete_all": "Видалити всі",
|
||||
"sent": "Звіт про збій надіслано.",
|
||||
"send_no_consent": "Спочатку ввімкніть звіти про збої.",
|
||||
"send_no_url": "Адресу не задано, тому надсилати нікуди. Введіть її вище.",
|
||||
"send_failed": "Не вдалося надіслати звіт про збій."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6643,5 +6643,30 @@
|
||||
"plugins_off": "Tổ chức của bạn đã tắt tiện ích trên thiết bị này.",
|
||||
"plugins_allowlist": "Tổ chức của bạn chỉ cho phép một số tiện ích trên thiết bị này.",
|
||||
"off": "Tắt"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "Thông tin sử dụng và báo cáo sự cố",
|
||||
"section_description": "Cả hai đều tắt cho đến khi bạn bật, và mỗi mục có công tắc riêng.",
|
||||
"telemetry_label": "Gửi thông tin sử dụng",
|
||||
"telemetry_description": "Mỗi ngày một lần: phiên bản ứng dụng, hệ điều hành của bạn và cách ứng dụng kết nối. Với máy chủ thư riêng, còn có các tính năng đang bật và số tài khoản ước tính. Không bao giờ gửi thư, địa chỉ hay tên máy chủ.",
|
||||
"crash_label": "Gửi báo cáo sự cố",
|
||||
"crash_description": "Lỗi trong ứng dụng được gửi kèm tên tệp từ mã của chính ứng dụng và thông báo ngắn đã được làm sạch. Nếu chính ứng dụng gặp sự cố, một tệp sự cố sẽ ở lại trên máy tính này và chỉ rời đi khi bạn gửi.",
|
||||
"crash_memory": "Tệp sự cố là bản sao một phần bộ nhớ của ứng dụng khi gặp sự cố, nên có thể chứa một phần thư đang mở. Bulwark xóa tên người dùng, địa chỉ và mã đăng nhập mà nó tìm thấy, nhưng không thể hứa tìm được tất cả.",
|
||||
"instance_note": "Trên máy chủ Bulwark, quản trị viên của máy chủ đó quyết định riêng những gì các trang báo cáo về.",
|
||||
"forced_off": "Tổ chức của bạn đã tắt mục này.",
|
||||
"url_label": "Nơi nhận tệp sự cố",
|
||||
"url_description": "Địa chỉ https nhận tệp sự cố. Bulwark không có sẵn địa chỉ nào.",
|
||||
"url_invalid": "Nhập địa chỉ https đầy đủ.",
|
||||
"url_save": "Lưu",
|
||||
"files_label": "Tệp sự cố trên máy tính này",
|
||||
"files_description": "Bulwark giữ các tệp mới nhất và xóa các tệp cũ hơn một tháng.",
|
||||
"files_empty": "Không có tệp sự cố.",
|
||||
"file_size": "{size} KB",
|
||||
"send": "Gửi báo cáo sự cố…",
|
||||
"delete_all": "Xóa tất cả",
|
||||
"sent": "Đã gửi báo cáo sự cố.",
|
||||
"send_no_consent": "Hãy bật báo cáo sự cố trước.",
|
||||
"send_no_url": "Chưa đặt địa chỉ nên không có nơi để gửi. Hãy nhập một địa chỉ ở trên.",
|
||||
"send_failed": "Không thể gửi báo cáo sự cố."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "您的組織已在此裝置上關閉外掛程式。",
|
||||
"plugins_allowlist": "您的組織在此裝置上只允許使用部分外掛程式。",
|
||||
"off": "關閉"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "使用資訊與當機報告",
|
||||
"section_description": "兩者在您開啟前都維持關閉,且各有獨立的開關。",
|
||||
"telemetry_label": "傳送使用資訊",
|
||||
"telemetry_description": "每天一次:應用程式版本、您的作業系統以及應用程式的連線方式。使用自己的郵件伺服器時,還包括已開啟的功能和大致的帳號數量。絕不傳送郵件、地址或伺服器名稱。",
|
||||
"crash_label": "傳送當機報告",
|
||||
"crash_description": "應用程式中的錯誤會連同應用程式本身程式碼的檔案名稱和經過清理的簡短訊息一起傳送。如果應用程式本身當機,當機檔案會留在這台電腦上,只有您傳送時才會離開。",
|
||||
"crash_memory": "當機檔案是當機時應用程式部分記憶體的副本,因此可能包含已開啟郵件的部分內容。Bulwark 會移除它找到的使用者名稱、地址和登入權杖,但無法保證找到全部。",
|
||||
"instance_note": "在 Bulwark 伺服器上,頁面向伺服器回報哪些內容由該伺服器的管理員另行決定。",
|
||||
"forced_off": "您的組織已關閉此功能。",
|
||||
"url_label": "當機檔案的去向",
|
||||
"url_description": "接收當機檔案的 https 地址。Bulwark 沒有預設地址。",
|
||||
"url_invalid": "請輸入完整的 https 地址。",
|
||||
"url_save": "儲存",
|
||||
"files_label": "這台電腦上的當機檔案",
|
||||
"files_description": "Bulwark 保留最新的檔案,並刪除超過一個月的檔案。",
|
||||
"files_empty": "沒有當機檔案。",
|
||||
"file_size": "{size} KB",
|
||||
"send": "傳送當機報告…",
|
||||
"delete_all": "全部刪除",
|
||||
"sent": "當機報告已傳送。",
|
||||
"send_no_consent": "請先開啟當機報告。",
|
||||
"send_no_url": "未設定地址,因此無處傳送。請在上方輸入一個。",
|
||||
"send_failed": "無法傳送當機報告。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6631,5 +6631,30 @@
|
||||
"plugins_off": "您的组织已在此设备上关闭插件。",
|
||||
"plugins_allowlist": "您的组织在此设备上只允许使用部分插件。",
|
||||
"off": "关闭"
|
||||
},
|
||||
"desktopTelemetry": {
|
||||
"section_title": "使用信息和崩溃报告",
|
||||
"section_description": "两者在您开启之前都处于关闭状态,并且各有独立的开关。",
|
||||
"telemetry_label": "发送使用信息",
|
||||
"telemetry_description": "每天一次:应用版本、您的操作系统以及应用的连接方式。使用自己的邮件服务器时,还包括已开启的功能和大致的帐户数量。绝不发送邮件、地址或服务器名称。",
|
||||
"crash_label": "发送崩溃报告",
|
||||
"crash_description": "应用中的错误会连同应用自身代码的文件名和经过清理的简短消息一起发送。如果应用本身崩溃,崩溃文件会留在这台电脑上,只有您发送时才会离开。",
|
||||
"crash_memory": "崩溃文件是崩溃时应用部分内存的副本,因此可能包含已打开邮件的部分内容。Bulwark 会删除它找到的用户名、地址和登录令牌,但不能保证找到全部。",
|
||||
"instance_note": "在 Bulwark 服务器上,页面向服务器报告哪些内容由该服务器的管理员另行决定。",
|
||||
"forced_off": "您的组织已关闭此功能。",
|
||||
"url_label": "崩溃文件的去向",
|
||||
"url_description": "接收崩溃文件的 https 地址。Bulwark 没有预设地址。",
|
||||
"url_invalid": "请输入完整的 https 地址。",
|
||||
"url_save": "保存",
|
||||
"files_label": "这台电脑上的崩溃文件",
|
||||
"files_description": "Bulwark 保留最新的文件,并删除超过一个月的文件。",
|
||||
"files_empty": "没有崩溃文件。",
|
||||
"file_size": "{size} KB",
|
||||
"send": "发送崩溃报告…",
|
||||
"delete_all": "全部删除",
|
||||
"sent": "崩溃报告已发送。",
|
||||
"send_no_consent": "请先开启崩溃报告。",
|
||||
"send_no_url": "未设置地址,因此无处发送。请在上方输入一个。",
|
||||
"send_failed": "无法发送崩溃报告。"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user