Merge branch 'feat/do-telemetry' into feat/desktop-offline

# Conflicts:
#	lib/desktop/types.ts
This commit is contained in:
Linus Rath
2026-11-03 17:42:32 +01:00
65 changed files with 2647 additions and 38 deletions
+4 -4
View File
@@ -18,7 +18,7 @@ vi.mock('@/lib/admin/session', async (original) => ({
getClientIP: () => client.ip,
}));
import { POST as checkIn, REGISTER_PER_HOUR } from '../route';
import { POST as checkIn } from '../route';
import { POST as wiped } from '../wiped/route';
import { GET as adminList, POST as adminAct } from '../../admin/devices/route';
import * as registry from '@/lib/devices/registry';
@@ -120,12 +120,12 @@ describe('POST /api/devices', () => {
it('holds new rows to a much lower limit per address than check-ins, and a 429 changes nothing', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const statuses: number[] = [];
for (let i = 0; i <= REGISTER_PER_HOUR; i += 1) {
for (let i = 0; i <= registry.REGISTER_PER_HOUR; i += 1) {
statuses.push((await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }))).status);
}
expect(statuses.slice(0, REGISTER_PER_HOUR).every((status) => status === 200)).toBe(true);
expect(statuses.slice(0, registry.REGISTER_PER_HOUR).every((status) => status === 200)).toBe(true);
expect(statuses.at(-1)).toBe(429);
expect((await registry.listDevices()).total).toBe(REGISTER_PER_HOUR);
expect((await registry.listDevices()).total).toBe(registry.REGISTER_PER_HOUR);
// A device that's already registered still hears about its wipe.
const [{ id }] = (await registry.listDevices()).devices;
+1 -7
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getClientIP } from '@/lib/admin/session';
import { checkInDevice, DEVICE_ID_RE, DEVICE_SECRET_RE, registerDevice, TooManyDevicesError } from '@/lib/devices/registry';
import { checkInDevice, DEVICE_ID_RE, DEVICE_SECRET_RE, REGISTER_PER_HOUR, registerDevice, TooManyDevicesError } from '@/lib/devices/registry';
import { logger } from '@/lib/logger';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStore } from '@/lib/shared-store';
@@ -11,12 +11,6 @@ const NO_STORE = { 'Cache-Control': 'no-store' };
const MAX_BODY_BYTES = 2048;
// Per address: a whole office can sit behind one, checking in as it starts up.
const PER_MINUTE = 300;
/**
* A new row is written to the state dir and stays half a year, for anyone with a
* session. Enough for an office turning this on in one morning: a device turned
* away tries again in a quarter of an hour.
*/
export const REGISTER_PER_HOUR = 60;
function cleanText(value: unknown): string {
if (typeof value !== 'string') return '';
+19
View File
@@ -102,3 +102,22 @@ describe('POST /api/errors', () => {
expect((await POST(post('not json'))).status).toBe(400);
});
});
describe('a desktop app\'s version', () => {
it.each(['0.3.0-beta.1+desktop', '1.20.0+desktop', '1.20.0-rc.2'])('keeps %s', async (version) => {
config.errorReportingDsn = DSN;
const res = await POST(post({ name: 'TypeError', frames: [], version }));
expect(res.status).toBe(204);
await flushErrors();
const day = (await readErrors())[utcDay()] ?? {};
expect(Object.values(day).map((entry) => entry.version)).toContain(version);
});
it.each(['1.20.0+a+b', '1.20.0-beta/../x', 'not a version'])('falls back to the server\'s for %s', async (version) => {
config.errorReportingDsn = DSN;
await POST(post({ name: 'RangeError', frames: [], version }));
await flushErrors();
const day = (await readErrors())[utcDay()] ?? {};
expect(Object.values(day).map((entry) => entry.version)).not.toContain(version);
});
});
+1 -1
View File
@@ -65,7 +65,7 @@ export async function POST(request: NextRequest) {
'browser',
// The version the tab runs (a tab left open across an update runs the
// old one, and its frames point into that); the server's when absent.
typeof body.version === 'string' && /^\d+\.\d+\.\d+(?:[-+][\w.-]{1,40})?$/.test(body.version) ? body.version : appVersion(),
typeof body.version === 'string' && /^\d+\.\d+\.\d+(?:-[\w.-]{1,40})?(?:\+[\w.-]{1,40})?$/.test(body.version) ? body.version : appVersion(),
);
countBrowserError();
return new NextResponse(null, { status: 204, headers: NO_STORE });
@@ -0,0 +1,189 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
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));
});
});
describe('the text', () => {
const en = JSON.parse(readFileSync(path.join(process.cwd(), 'locales/en/common.json'), 'utf8')).desktopTelemetry as Record<string, string>;
it('names everything a heartbeat holds, in plain words', () => {
for (const word of [/a random ID/, /app version/, /Node version/, /operating system/, /how long the app has been running/, /how many plugins and themes/]) {
expect(en.telemetry_description).toMatch(word);
}
expect(en.telemetry_description).not.toMatch(/uptime/i);
});
it('says crash reports carry a random ID too', () => {
expect(en.crash_description).toMatch(/a random ID/);
});
it('says a crash file can still hold the text of mail after scrubbing', () => {
expect(en.crash_memory).toMatch(/can still contain the text of your mail/);
});
});
@@ -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>
);
}
+2
View File
@@ -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 { DesktopOsSettings } from './desktop-os-settings';
import { useTour } from '@/components/tour/tour-provider';
@@ -30,6 +31,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')}>
+3
View File
@@ -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) });
+26
View File
@@ -0,0 +1,26 @@
// Not a test: loads the collector's real validator for the tests that check against it.
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
export interface CollectorValidator {
validate(input: unknown): { schema: string; build: string | null; platform: string; version: string; os_family: string; node_version: string | null } | null;
validateErrorBatch(input: unknown): { errors: { name: string; count: string; days: number; version: string; frames: unknown[] }[] } | null;
}
/**
* The validator on the branch the desktop app ships against, read from git so it doesn't
* matter what the dashboard checkout has out. Null where there's no dashboard repo (CI).
*/
export async function loadCollectorValidator(): Promise<CollectorValidator | null> {
const repo = process.env.BULWARK_DASHBOARD_DIR ?? 'C:/Users/info/Downloads/jmap-webmail/repos/dashboard';
const ref = process.env.BULWARK_DASHBOARD_REF ?? 'feat/desktop-platform';
if (!existsSync(repo)) return null;
const shown = spawnSync('git', ['-C', repo, 'show', `${ref}:telemetry-collector/src/validate.ts`], { encoding: 'utf8' });
if (shown.status !== 0) throw new Error(`no validator at ${ref}: ${shown.stderr}`);
const file = path.join(mkdtempSync(path.join(tmpdir(), 'bw-validator-')), 'validate.ts');
writeFileSync(file, shown.stdout);
return await import(/* @vite-ignore */ pathToFileURL(file).href) as CollectorValidator;
}
@@ -0,0 +1,129 @@
// @vitest-environment node
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// What's on disk can't aim the desktop app's telemetry anywhere: a state file or a config
// file is something any program on the machine can write. Nothing here reaches a network.
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/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: {} }),
getAll: () => ({}),
get: (key: string, fallback: unknown) => (key === 'errorReportingDsn' ? flag.dsn : fallback),
},
}));
const sent = vi.fn(async (_url: string, _init?: { body?: string }) => new Response('{"ok":true}'));
vi.mock('@/lib/telemetry/endpoint-guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/telemetry/endpoint-guard')>();
return {
...actual,
resolveEndpointAllowed: vi.fn(async () => ({ ok: true })),
fetchTelemetryTarget: (url: string, init?: { body?: string }) => sent(url, init),
};
});
import { DEFAULT_ENDPOINT, loadState, saveState, sendOnce } from '@/lib/telemetry';
import { resetInstantErrorsForTests, sendInstantErrors } from '@/lib/telemetry/instant-errors';
import { flushErrors, recordError, resetAggregateForTests } from '@/lib/error-reporting/aggregate';
import { browserErrorSettings, errorReportingSettings } from '@/lib/error-reporting/settings';
import { captureDesktopMainError } from '@/lib/error-reporting/server';
const PRIVACY = Symbol.for('bulwark.desktop.privacy');
const holder = globalThis as unknown as Record<symbol, unknown>;
const EVIL = 'https://collector.evil.example/v1/heartbeat';
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.evil.example/3';
let dir = '';
beforeEach(async () => {
flag.IS_DESKTOP_BUILD = true;
flag.dsn = '';
sent.mockClear();
dir = mkdtempSync(path.join(tmpdir(), 'bw-telemetry-endpoint-'));
vi.stubEnv('TELEMETRY_DATA_DIR', dir);
vi.stubEnv('BULWARK_TELEMETRY', '');
resetAggregateForTests();
resetInstantErrorsForTests();
holder[PRIVACY] = { telemetry: true, crashReports: true, appVersion: '0.3.0', build: 'desktop.standalone.stable' };
await saveState({ consent: 'off', endpoint: EVIL, consentedAt: null, lastSentAt: null, nextScheduledAt: null });
});
afterEach(() => {
delete holder[PRIVACY];
vi.unstubAllEnvs();
vi.unstubAllGlobals();
rmSync(dir, { recursive: true, force: true });
});
describe('the desktop build', () => {
it('sends a heartbeat to the project\'s collector, whatever the state file names', async () => {
expect((await sendOnce()).ok).toBe(true);
expect(sent.mock.calls.map(([url]) => url)).toEqual([DEFAULT_ENDPOINT]);
});
it('isn\'t switched off by a blank endpoint in the state file either: consent is the switch', async () => {
await saveState({ ...(await loadState()), endpoint: '' });
expect((await sendOnce()).ok).toBe(true);
expect(sent.mock.calls.map(([url]) => url)).toEqual([DEFAULT_ENDPOINT]);
});
it('sends error counts there too', async () => {
recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop');
await flushErrors();
await sendInstantErrors();
expect(sent.mock.calls.map(([url]) => url)).toEqual([new URL('errors', DEFAULT_ENDPOINT).toString()]);
});
it('has no DSN, whatever a config file names', async () => {
flag.dsn = DSN;
expect(errorReportingSettings().dsn).toBe('');
expect((await browserErrorSettings()).dsn).toBe('');
});
it('sends a main-process error to no config-file DSN, with consent on', async () => {
flag.dsn = DSN;
const fetchSpy = vi.fn().mockResolvedValue(new Response(null));
vi.stubGlobal('fetch', fetchSpy);
await captureDesktopMainError({ name: 'TypeError', message: 'x', stack: 'TypeError: x\n at f (/desktop/main.mjs:1:2)' });
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe('a server build', () => {
beforeEach(() => {
flag.IS_DESKTOP_BUILD = false;
vi.stubEnv('BULWARK_TELEMETRY', 'on');
});
it('still uses the endpoint its admin stored', async () => {
await saveState({ ...(await loadState()), endpoint: 'https://collector.example.org/v1/heartbeat' });
await sendOnce();
expect(sent.mock.calls.map(([url]) => url)).toEqual(['https://collector.example.org/v1/heartbeat']);
});
it('still takes a blank endpoint as off', async () => {
await saveState({ ...(await loadState()), endpoint: '' });
expect(await sendOnce()).toMatchObject({ ok: false, error: 'endpoint blank' });
expect(sent).not.toHaveBeenCalled();
});
it('still has its admin\'s DSN', async () => {
flag.dsn = DSN;
expect(errorReportingSettings().dsn).toBe(DSN);
expect((await browserErrorSettings()).dsn).toBe(DSN);
});
});
+397
View File
@@ -0,0 +1,397 @@
// @vitest-environment node
import { mkdtempSync, readFileSync, rmSync } 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 { 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';
import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry';
import { DESKTOP_CATCH_UP_MAX_MS, DESKTOP_CATCH_UP_MIN_MS } from '@/lib/desktop/desktop-telemetry';
import { loadCollectorValidator } from './collector-validator';
const collector = await loadCollectorValidator();
interface Received {
path: string;
body: Record<string, unknown>;
}
let server: Server;
let received: Received[] = [];
let answer = 200;
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', endpoint: `${base}/v1/heartbeat` } : 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(answer, { '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 = [];
answer = 200;
dir = mkdtempSync(path.join(tmpdir(), 'bw-telemetry-desktop-'));
vi.stubEnv('TELEMETRY_DATA_DIR', dir);
vi.stubEnv('BULWARK_TELEMETRY_ALLOW_PRIVATE', '1');
vi.stubEnv('BULWARK_TELEMETRY', '');
vi.stubEnv('BULWARK_TELEMETRY_DISABLED', '');
vi.stubEnv('BULWARK_TELEMETRY_ERRORS', '');
shellSays(null);
resetAggregateForTests();
resetInstantErrorsForTests();
// Never used on the desktop build. Reaching it would show up as a failed send.
await saveState({ consent: 'off', endpoint: 'http://127.0.0.1:1/ignored', 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');
expect(clean.platform).toBe('desktop');
// 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.each([
[0, DESKTOP_CATCH_UP_MIN_MS],
[0.5, (DESKTOP_CATCH_UP_MIN_MS + DESKTOP_CATCH_UP_MAX_MS) / 2],
[0.999999, DESKTOP_CATCH_UP_MAX_MS],
])('after a restart with a slot in the past, at random %s it goes after about %s ms, not an hour', async (random, expected) => {
shellSays({ telemetry: true, crashReports: false });
vi.spyOn(Math, 'random').mockReturnValue(random);
await saveState({ ...(await loadState()), nextScheduledAt: new Date(Date.now() - 3 * 86_400_000).toISOString() });
await startScheduler();
const wait = await nextIn();
vi.restoreAllMocks();
expect(wait).toBeGreaterThan(expected - 5_000);
expect(wait).toBeLessThanOrEqual(expected);
});
it('the wait is between 30 s and 10 min, and differs from start to start', () => {
expect(DESKTOP_CATCH_UP_MIN_MS).toBe(30_000);
expect(DESKTOP_CATCH_UP_MAX_MS).toBe(600_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(DESKTOP_CATCH_UP_MAX_MS);
});
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()), endpoint: `${base}/v1/heartbeat`, 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('BULWARK_TELEMETRY=off', () => {
beforeEach(() => {
vi.stubEnv('BULWARK_TELEMETRY', 'off');
shellSays({ telemetry: true, crashReports: true });
});
it('beats the user\'s yes for the heartbeat', async () => {
expect((await sendOnce()).ok).toBe(false);
expect(received).toEqual([]);
});
it('beats it for error counts too, and nothing is collected', async () => {
expect(await shouldCollectErrors()).toBe(false);
recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop');
await flushErrors();
await sendInstantErrors();
expect(received).toEqual([]);
});
it('the legacy variable does the same', async () => {
vi.stubEnv('BULWARK_TELEMETRY', '');
vi.stubEnv('BULWARK_TELEMETRY_DISABLED', '1');
expect((await sendOnce()).ok).toBe(false);
expect(await shouldCollectErrors()).toBe(false);
expect(received).toEqual([]);
});
it('=on is no yes by itself', async () => {
vi.stubEnv('BULWARK_TELEMETRY', 'on');
shellSays({ telemetry: false, crashReports: false });
expect((await sendOnce()).ok).toBe(false);
expect(received).toEqual([]);
});
it('the list of outgoing hosts says how the desktop app differs', () => {
const entry = OUTGOING_HOSTS.find((host) => host.id === 'telemetry')!;
expect(entry.usedBy).toMatch(/crash report consent alone/i);
expect(entry.off).toMatch(/desktop app/i);
expect(entry.off).toMatch(/no admin page/i);
});
});
describe('a refusal from the collector', () => {
it('isn\'t retried on every tick', async () => {
answer = 400;
shellSays({ telemetry: false, crashReports: true });
recordError({ name: 'TypeError', frames: [] }, 'server', '0.3.0-beta.1+desktop');
await flushErrors();
await sendInstantErrors();
await sendInstantErrors();
await sendInstantErrors();
expect(received).toHaveLength(1);
});
it('a passing failure is tried again', async () => {
answer = 503;
shellSays({ telemetry: false, crashReports: true });
recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop');
await flushErrors();
await sendInstantErrors();
await sendInstantErrors();
expect(received).toHaveLength(2);
});
it('rate limiting is tried again', async () => {
answer = 429;
shellSays({ telemetry: false, crashReports: true });
recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop');
await flushErrors();
await sendInstantErrors();
await sendInstantErrors();
expect(received).toHaveLength(2);
});
it.skipIf(!collector)('a beta version with the tag is what the patched collector takes', async () => {
shellSays({ telemetry: false, crashReports: true });
recordError({ name: 'TypeError', frames: [] }, 'server', '0.3.0-beta.1+desktop');
await flushErrors();
await sendInstantErrors();
expect(collector!.validateErrorBatch(received[0].body)!.errors[0].version).toBe('0.3.0-beta.1+desktop');
});
it('a server build still retries as before', async () => {
flag.IS_DESKTOP_BUILD = false;
vi.stubEnv('BULWARK_TELEMETRY', 'on');
vi.stubEnv('BULWARK_TELEMETRY_ERRORS', 'instant');
answer = 400;
await saveState({ ...(await loadState()), endpoint: `${base}/v1/heartbeat` });
recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0');
await flushErrors();
await sendInstantErrors();
await sendInstantErrors();
expect(received).toHaveLength(2);
});
});
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', endpoint: `${base}/v1/heartbeat` });
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 on a machine with the repo.
it.skipIf(process.env.CI)('finds the collector on this machine', () => {
expect(collector).not.toBeNull();
});
+2 -2
View File
@@ -39,8 +39,8 @@ 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.',
off: 'Off by default. BULWARK_TELEMETRY=off, or System > Usage stats (switch it off or clear the collector URL).',
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. In the desktop app the user decides: the heartbeat with its usage info switch, and with crash report consent alone the error counts, with no heartbeat.',
off: 'Off by default. BULWARK_TELEMETRY=off, or System > Usage stats (switch it off or clear the collector URL). The desktop app has no admin page and takes no collector URL: its two switches in Settings, the TelemetryAllowed and CrashReportsAllowed policies, or BULWARK_TELEMETRY=off in its environment, which stops both.',
inMode: 'Never sent, whatever the telemetry setting says.',
lite: false,
},
@@ -0,0 +1,160 @@
// @vitest-environment node
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { loadCollectorValidator } from '@/lib/__tests__/collector-validator';
import {
DESKTOP_CATCH_UP_MAX_MS,
DESKTOP_CATCH_UP_MIN_MS,
DESKTOP_HEARTBEAT_INTERVAL_MS,
buildDesktopErrorBatch,
buildDesktopPing,
consentInForce,
desktopBuildTag,
desktopCatchUpMs,
desktopChannel,
desktopOsFamily,
forcedOffByPolicy,
heartbeatDelayMs,
} from '../desktop-telemetry';
const collector = await loadCollectorValidator();
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');
const CATCH_UP = 123_000;
it('soon, with none sent yet or the last one long ago', () => {
expect(heartbeatDelayMs(undefined, now, CATCH_UP)).toBe(CATCH_UP);
expect(heartbeatDelayMs(null, now, CATCH_UP)).toBe(CATCH_UP);
expect(heartbeatDelayMs(now - 3 * DESKTOP_HEARTBEAT_INTERVAL_MS, now, CATCH_UP)).toBe(CATCH_UP);
});
it('a day after the last, and not sooner', () => {
expect(heartbeatDelayMs(now - 3_600_000, now, CATCH_UP)).toBe(DESKTOP_HEARTBEAT_INTERVAL_MS - 3_600_000);
});
it('soon again if the clock went back', () => {
expect(heartbeatDelayMs(now + 3_600_000, now, CATCH_UP)).toBe(CATCH_UP);
});
it('soon means somewhere from 30 s to 10 min, so a start time can\'t be read from it', () => {
expect(desktopCatchUpMs(() => 0)).toBe(30_000);
expect(desktopCatchUpMs(() => 0.5)).toBe(315_000);
expect(desktopCatchUpMs(() => 0.999999)).toBeLessThanOrEqual(600_000);
expect(desktopCatchUpMs(() => 0.999999)).toBeGreaterThan(599_000);
expect(DESKTOP_CATCH_UP_MIN_MS).toBe(30_000);
expect(DESKTOP_CATCH_UP_MAX_MS).toBe(600_000);
const seen = new Set(Array.from({ length: 50 }, () => desktopCatchUpMs()));
expect(seen.size).toBeGreaterThan(1);
for (const value of seen) {
expect(value).toBeGreaterThanOrEqual(30_000);
expect(value).toBeLessThanOrEqual(600_000);
}
expect(heartbeatDelayMs(null, now)).toBeLessThanOrEqual(600_000);
});
});
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(clean.platform).toBe('desktop');
// 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(collector!.validateErrorBatch(buildDesktopErrorBatch('f0c1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b', now, [
{ name: 'TypeError', fingerprint: '0123456789abcdef', version: '0.3.0-beta.1+desktop', count: 1 },
]))).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' });
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* 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;
/** Only a test build of the shell sets this. Nothing on disk can: see `telemetryEndpoint`. */
endpoint?: 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. Random, so
* the collector can't read when someone starts their computer from when it arrives.
*/
export const DESKTOP_CATCH_UP_MIN_MS = 30_000;
export const DESKTOP_CATCH_UP_MAX_MS = 600_000;
export function desktopCatchUpMs(random: () => number = Math.random): number {
return DESKTOP_CATCH_UP_MIN_MS + Math.floor(random() * (DESKTOP_CATCH_UP_MAX_MS - DESKTOP_CATCH_UP_MIN_MS));
}
/** 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, catchUp: number = desktopCatchUpMs()): number {
if (!lastSentAt || lastSentAt > now) return catchUp;
const left = lastSentAt + DESKTOP_HEARTBEAT_INTERVAL_MS - now;
return left > 0 ? left : catchUp;
}
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,
})),
};
}
+8
View File
@@ -9,3 +9,11 @@ import { getDesktopBridge } from './bridge';
export function clearKeystoreGrants(): void {
void Promise.resolve(getDesktopBridge()?.keystore?.clearGrants?.()).catch(() => {});
}
/**
* Deletes the crash files on this computer. They can hold the mail of whoever was signed
* in when the app crashed, so the next account at this computer doesn't get to send them.
*/
export function clearDesktopCrashFiles(): void {
void Promise.resolve(getDesktopBridge()?.privacy?.deleteCrashes?.()).catch(() => {});
}
+2 -2
View File
@@ -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: { repo: 'shell', file: 'src/main/features/policies.ts' },
+33
View File
@@ -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);
}
+19
View File
@@ -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;
}
+38
View File
@@ -315,6 +315,43 @@ export interface DesktopOsBridge {
openDefaultAppSettings: () => Promise<DesktopOsDefaultAppStatus>;
}
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';
@@ -330,6 +367,7 @@ export interface BulwarkDesktopBridge {
auth?: DesktopAuthBridge;
plugins?: DesktopPluginsBridge;
keystore?: DesktopKeystoreBridge;
privacy?: DesktopPrivacyBridge;
}
declare global {
+6
View File
@@ -19,6 +19,12 @@ import { settingsId } from '@/lib/settings-sync';
import { getStore, type CollectionOptions } from '@/lib/shared-store';
import type { DeviceView, WipeReason } from './types';
/**
* A new row is written to the state dir and stays half a year, for anyone with a
* session. Enough for an office turning this on in one morning: a device turned
* away tries again in a quarter of an hour.
*/
export const REGISTER_PER_HOUR = 60;
export const DEVICE_ID_RE = /^[0-9a-f]{64}\/[0-9a-f]{32}$/;
export const DEVICE_SECRET_RE = /^[0-9a-f]{64}$/;
const USER_KEY_RE = /^[0-9a-f]{64}$/;
@@ -0,0 +1,86 @@
// @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
// them. 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';
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;
// The frames' rule is compiled into this build only.
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
flag.dsn = '';
fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
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('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');
});
});
@@ -0,0 +1,235 @@
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', () => {
beforeEach(() => {
// The rule is compiled into the desktop build only.
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
});
afterEach(() => {
vi.unstubAllEnvs();
});
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('a server install', () => {
it('sees no difference: a /desktop/ path is just another path', () => {
expect(scrubFramePath('/desktop/main.mjs')).toBe('[other]');
expect(parseStack('TypeError: x\n at async /desktop/main.mjs:90:3')[0].path).toBe('[other]');
expect(reportFromError(withStack('TypeError: x\n at a (/desktop/main.mjs:1:2)'), 'onerror', false)).toBeNull();
});
it('keeps reading an anonymous async frame as it did', () => {
// scrubFunctionName and the marker search see the same text as before this existed.
const [frame] = parseStack('TypeError: x\n at async https://mail.example.org/_next/static/chunks/1.js:2:30');
expect(frame.path).toBe('/_next/static/chunks/1.js');
expect(parseStack('TypeError: x\n at async node:internal/x:1:2')[0].path).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);
});
describe('consent taken back with a page open', () => {
function privacyBridge(on: boolean) {
let listener: ((state: unknown) => void) | undefined;
const say = (next: boolean, forcedOff = false) => listener?.({ telemetry: { on: false, forcedOff: false }, crashReports: { on: next, forcedOff, uploadUrl: '' } });
setBridge({
version: 1,
platform: 'win32',
mode: 'instance',
privacy: {
get: async () => ({ telemetry: { on: false, forcedOff: false }, crashReports: { on, forcedOff: false, uploadUrl: '' } }),
onChange: (next: (state: unknown) => void) => {
listener = next;
return () => {};
},
},
});
return say;
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
it('stops an open page sending to a DSN', async () => {
const say = privacyBridge(true);
configureErrorReporting(SETTINGS);
await settle();
captureException(bundleError());
expect(fetchSpy).toHaveBeenCalledTimes(1);
say(false);
captureException(withStack('TypeError: other\n at b (https://mail.example.org/_next/static/chunks/2.js:2:30)'));
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('and a config arriving afterwards doesn\'t bring it back', async () => {
const say = privacyBridge(true);
configureErrorReporting(SETTINGS);
await settle();
say(false);
configureErrorReporting(SETTINGS);
captureException(bundleError());
expect(fetchSpy).not.toHaveBeenCalled();
});
it('a change to something else, with crash reports never on, leaves an instance\'s own settings alone', async () => {
const say = privacyBridge(false);
configureErrorReporting(SETTINGS);
await settle();
say(false);
captureException(bundleError());
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('a policy forcing it off stops it, however it stood', async () => {
const say = privacyBridge(false);
configureErrorReporting(SETTINGS);
await settle();
say(false, true);
captureException(bundleError());
expect(fetchSpy).not.toHaveBeenCalled();
});
it('saying yes again lifts it', async () => {
const say = privacyBridge(true);
configureErrorReporting(SETTINGS);
await settle();
say(false);
say(true);
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})?$/);
});
});
+49 -4
View File
@@ -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 {
@@ -41,6 +42,10 @@ interface ClientState {
breadcrumbs: Breadcrumb[];
limiter: ReportLimiter | null;
installed: boolean;
/** In the desktop app: the shell's privacy changes are being listened to. */
watchingConsent: boolean;
/** The user took crash reports back, or a policy did. A later config doesn't undo that. */
withdrawn: boolean;
}
// On globalThis: the error boundary, the JMAP client and the root component
@@ -48,7 +53,7 @@ interface ClientState {
const KEY = Symbol.for('bulwark.error-reporting.client');
function state(): ClientState {
const g = globalThis as typeof globalThis & { [KEY]?: ClientState };
return (g[KEY] ??= { settings: undefined, pending: [], breadcrumbs: [], limiter: null, installed: false });
return (g[KEY] ??= { settings: undefined, pending: [], breadcrumbs: [], limiter: null, installed: false, watchingConsent: false, withdrawn: false });
}
function sessionStore(): Storage | null {
@@ -73,10 +78,36 @@ function currentRoute(): string | undefined {
return scrubRoutePath(location.pathname, basePath());
}
/**
* Consent taken back while a page is open, in the desktop app. A change to anything else
* with crash reports never on leaves an instance's own settings alone: its admin chose
* those, and the settings page says so.
*/
function watchDesktopConsent(): void {
const s = state();
const bridge = getDesktopBridge();
if (s.watchingConsent || !bridge?.privacy?.onChange) return;
s.watchingConsent = true;
let on: boolean | null = null;
void Promise.resolve(bridge.privacy.get?.()).then((current) => {
on ??= current?.crashReports.on ?? null;
}).catch(() => {});
bridge.privacy.onChange((next) => {
const tookBack = on === true && !next.crashReports.on;
on = next.crashReports.on;
if (on) s.withdrawn = false;
if (tookBack || next.crashReports.forcedOff) {
s.withdrawn = true;
configureErrorReporting(null);
}
});
}
/** Apply the settings from /api/config (or Lite's policy.json); null switches reporting off. */
export function configureErrorReporting(settings: ClientSettings | null): void {
const s = state();
const on = settings && (parseDsn(settings.dsn) || settings.collect) ? settings : null;
watchDesktopConsent();
const on = !s.withdrawn && settings && (parseDsn(settings.dsn) || settings.collect) ? settings : null;
s.settings = on;
const queued = s.pending;
s.pending = [];
@@ -176,10 +207,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 +234,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.
+7 -1
View File
@@ -1,3 +1,4 @@
import { DESKTOP_VERSION_SUFFIX } from '@/lib/desktop/desktop-telemetry';
import type { Frame } from './stack';
/**
@@ -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;
}
+9 -1
View File
@@ -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,18 @@ 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, in the desktop build `/desktop/...` for its 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, '/');
// Spelled out, so a server build compiles this away and reads paths as it always did.
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && 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) {
+25
View File
@@ -95,6 +95,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.
+5 -1
View File
@@ -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';
/**
@@ -32,7 +33,8 @@ export function parseEnvironment(value: unknown): string {
export function errorReportingSettings(): ErrorReportingSettings {
const raw = configManager.get<string>('errorReportingDsn', '');
return {
dsn: parseDsn(raw) ? raw.trim() : '',
// A desktop user has no admin, so a config file can't name somewhere to send errors.
dsn: !IS_DESKTOP_BUILD && parseDsn(raw) ? raw.trim() : '',
sampleRate: parseSampleRate(configManager.get<string>('errorReportingSampleRate', '1')),
environment: parseEnvironment(configManager.get<string>('errorReportingEnvironment', 'production')),
};
@@ -45,6 +47,8 @@ export function errorReportingSettings(): ErrorReportingSettings {
*/
export async function shouldCollectErrors(): Promise<boolean> {
await configManager.ensureLoaded();
// The desktop user's own yes, and nothing else.
if (IS_DESKTOP_BUILD) return (await effectiveErrorsConsent()).consent !== 'off';
if (errorReportingSettings().dsn || metricsEnabled()) return true;
return (await effectiveErrorsConsent()).consent !== 'off';
}
+4 -1
View File
@@ -33,7 +33,10 @@ 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, as main's stacks have them. Spelled
// out, so a server build compiles this away.
const file = process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? rawFile.replace(/^async /, '') : rawFile;
// "at fn (native)" and eval chains carry no file worth keeping.
frames.push({
path: scrubFramePath(file),
+7 -4
View File
@@ -1,9 +1,10 @@
import { logger } from '@/lib/logger';
import { readErrors, utcDay } from '@/lib/error-reporting/aggregate';
import { effectiveConsent, effectiveErrorsConsent, endpointEnabled, getInstanceId } from './state';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { effectiveConsent, effectiveErrorsConsent, endpointEnabled, getInstanceId, telemetryEndpoint } from './state';
import { errorsForDay } from './payload';
import { fetchTelemetryTarget, resolveEndpointAllowed } from './endpoint-guard';
import { DEFAULT_ENDPOINT, errorsEndpointFor, type TelemetryErrorBatch } from './types';
import { errorsEndpointFor, type TelemetryErrorBatch } from './types';
/**
* Instant error counts. With the error-count consent on "instant", the
@@ -45,7 +46,7 @@ async function tick(now: number): Promise<void> {
return;
}
const { state: stored } = await effectiveConsent();
const endpoint = stored.endpoint ?? DEFAULT_ENDPOINT;
const endpoint = telemetryEndpoint(stored);
if (!endpointEnabled(endpoint)) return;
let target: string;
try {
@@ -81,7 +82,9 @@ async function tick(now: number): Promise<void> {
if (!res.ok) {
// Not marked as sent: the next tick tries them again.
logger.warn('telemetry: instant error counts rejected', { status: res.status });
return;
// A 4xx from the collector stays a 4xx. A desktop app, often a beta on a collector
// that doesn't know its version yet, shouldn't knock on the door every minute.
if (!IS_DESKTOP_BUILD || res.status < 400 || res.status >= 500 || res.status === 429) return;
}
for (const error of changed) s.sent.set(`${error.fingerprint}|${error.version}`, error.count);
}
+9
View 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()) };
+18 -8
View File
@@ -2,11 +2,12 @@ import { logger } from '@/lib/logger';
import { isSharedMode } from '@/lib/shared-store';
import { announceChange } from '@/lib/shared-store/changes';
import {
effectiveConsent, endpointEnabled, loadState, loadStateVersioned, saveStateIfUnchanged, updateState,
effectiveConsent, endpointEnabled, loadState, loadStateVersioned, saveStateIfUnchanged, telemetryEndpoint, updateState,
} from './state';
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_RETRY_MS, desktopCatchUpMs } from '@/lib/desktop/desktop-telemetry';
const DAY_MS = 24 * 60 * 60 * 1000;
const JITTER_MS = 2 * 60 * 60 * 1000; // ± 2 hours
@@ -96,7 +97,7 @@ export async function sendOnce(opts?: { reason?: string; slot?: string }): Promi
if (consent !== 'on') return { ok: false, error: `consent ${consent} (source ${source})` };
// A cleared collector URL is stored as '' and switches sending off; only a
// state file without the field at all gets the project's collector.
const endpoint = state.endpoint ?? DEFAULT_ENDPOINT;
const endpoint = telemetryEndpoint(state);
if (!endpointEnabled(endpoint)) return { ok: false, error: 'endpoint blank' };
// Re-check at fetch time: defeats DNS rebinding, and catches the case
@@ -164,12 +165,18 @@ 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 });
// A 4xx says the collector won't take this, and an hour from now it still won't.
const refused = result.status !== undefined && result.status >= 400 && result.status < 500 && result.status !== 429;
failed = !result.ok && result.error !== 'already sent' && !refused;
} 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.
@@ -181,13 +188,16 @@ export async function startScheduler(): Promise<void> {
return;
}
const state = await loadState();
if (!endpointEnabled(state.endpoint ?? DEFAULT_ENDPOINT)) {
const endpoint = telemetryEndpoint(state);
if (!endpointEnabled(endpoint)) {
logger.info('telemetry: scheduler not started', { consent, endpoint: 'blank' });
return;
}
// 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, at a random delay.
let delay = IS_DESKTOP_BUILD ? desktopCatchUpMs() : 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);
@@ -195,7 +205,7 @@ export async function startScheduler(): Promise<void> {
await scheduleNext(delay, generation);
logger.info('telemetry: scheduler started', {
nextInMs: delay,
endpoint: state.endpoint,
endpoint,
});
}
+21
View File
@@ -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,13 @@ 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.
// BULWARK_TELEMETRY=off stops these too, so it means what it says on a managed machine.
if (IS_DESKTOP_BUILD) {
const on = desktopServerPrivacy()?.crashReports && envOverride() !== 'off' && errorsEnvOverride() !== 'off';
return { consent: on ? 'instant' : 'off', source: 'env' };
}
if (telemetry.consent !== 'on') return { consent: 'off', source: 'telemetry' };
const env = errorsEnvOverride();
if (env) return { consent: env, source: 'env' };
@@ -213,10 +222,22 @@ 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. Nothing on disk can say yes for them, and in the
// environment only an off counts: main passes that through and nothing else.
if (IS_DESKTOP_BUILD) return { consent: desktopServerPrivacy()?.telemetry && envState !== 'off' ? 'on' : 'off', source: 'env', state };
if (envState) return { consent: envState, source: 'env', state };
return { consent: state.consent, source: 'file', state };
}
/**
* Where heartbeats go. On the desktop build the state file has no say, since anything on
* the machine can write to it. A desktop user has their two switches instead.
*/
export function telemetryEndpoint(state: TelemetryStateFile): string {
if (IS_DESKTOP_BUILD) return desktopServerPrivacy()?.endpoint ?? DEFAULT_ENDPOINT;
return state.endpoint ?? DEFAULT_ENDPOINT;
}
export function endpointEnabled(endpoint: string | undefined): boolean {
return !!endpoint && endpoint.trim().length > 0;
}
+1 -1
View File
@@ -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+';
+25
View File
@@ -6739,5 +6739,30 @@
"plugins_off": "أوقفت مؤسستك الإضافات على هذا الجهاز.",
"plugins_allowlist": "لا تسمح مؤسستك إلا بإضافات معينة على هذا الجهاز.",
"off": "إيقاف"
},
"desktopTelemetry": {
"section_title": "معلومات الاستخدام وتقارير الأعطال",
"section_description": "كلاهما متوقف حتى تشغّله، ولكل منهما مفتاحه الخاص.",
"telemetry_label": "إرسال معلومات الاستخدام",
"telemetry_description": "مرة واحدة في اليوم: معرّف عشوائي وإصدار التطبيق وإصدار Node ونظام التشغيل ومدة تشغيل التطبيق وطريقة اتصاله. ومع خادم بريد خاص بك، أيضًا الميزات المفعّلة وعدد تقريبي للحسابات وعدد الإضافات والسمات. لا يُرسل أبدًا البريد أو العناوين أو أسماء الخوادم.",
"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": "تعذّر إرسال تقرير العطل."
}
}
+25
View File
@@ -6739,5 +6739,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: un identificador aleatori, la versió de l'aplicació, la versió de Node, el teu sistema operatiu, quant de temps fa que funciona l'aplicació i com es connecta. Amb un servidor de correu propi, també quines funcions estan actives, un nombre aproximat de comptes i quants connectors i temes tens. Mai correus, adreces ni noms de servidor.",
"crash_label": "Envia informes d'errors greus",
"crash_description": "Els errors de l'aplicació s'envien amb un identificador aleatori, 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. Bulwark elimina els noms d'usuari, les adreces i els testimonis d'inici de sessió que troba, però un fitxer d'error encara pot contenir el text dels teus correus.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,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ě: náhodné ID, verze aplikace, verze Node, váš operační systém, jak dlouho aplikace běží a jak se připojuje. U vlastního poštovního serveru také zapnuté funkce, přibližný počet účtů a počet pluginů a motivů. 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áhodným ID, 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. Bulwark odstraní uživatelská jména, adresy a přihlašovací tokeny, které najde, ale soubor o pádu může přesto obsahovat text vaší pošty.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,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: et tilfældigt id, appens version, Node-versionen, dit styresystem, hvor længe appen har kørt, og hvordan den forbinder. Med din egen mailserver også hvilke funktioner der er slået til, et omtrentligt antal konti og hvor mange plugins og temaer du har. Aldrig mails, adresser eller servernavne.",
"crash_label": "Send nedbrudsrapporter",
"crash_description": "Fejl i appen sendes med et tilfældigt id, 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. Bulwark fjerner de brugernavne, adresser og login-tokens, det finder, men en nedbrudsfil kan stadig indeholde teksten fra dine mails.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,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: eine zufällige ID, die App-Version, die Node-Version, Ihr Betriebssystem, wie lange die App schon läuft und die Art der Verbindung. Bei einem eigenen Mailserver auch, welche Funktionen aktiv sind, eine ungefähre Zahl der Konten und wie viele Plugins und Designs Sie haben. Niemals E-Mails, Adressen oder Servernamen.",
"crash_label": "Absturzberichte senden",
"crash_description": "Fehler in der App werden mit einer zufälligen ID, 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. Bulwark entfernt Benutzernamen, Adressen und Anmeldetokens, die es findet, aber eine Absturzdatei kann trotzdem den Text Ihrer E-Mails enthalten.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,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: a random ID, the app version, the Node version, your operating system, how long the app has been running and how it connects. With your own mail server, also which features are on, a rough number of accounts and how many plugins and themes you have. Never mail, addresses or server names.",
"crash_label": "Send crash reports",
"crash_description": "Errors in the app go out with a random ID, 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. Bulwark removes user names, addresses and sign-in tokens it can find, but a crash file can still contain the text of your mail.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,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: un ID aleatorio, la versión de la aplicación, la versión de Node, tu sistema operativo, cuánto tiempo lleva abierta la aplicación y cómo se conecta. Con tu propio servidor de correo, también qué funciones están activas, un número aproximado de cuentas y cuántos plugins y temas tienes. 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 un ID aleatorio, 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. Bulwark elimina los nombres de usuario, las direcciones y los tokens de inicio de sesión que encuentra, pero un archivo de fallo aún puede contener el texto de tus correos.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,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: juhuslik ID, rakenduse versioon, Node'i versioon, teie operatsioonisüsteem, kui kaua rakendus on töötanud ja kuidas see ühendub. Oma meiliserveri puhul ka see, millised funktsioonid on sees, kontode ligikaudne arv ning pistikprogrammide ja kujunduste arv. Mitte kunagi kirju, aadresse ega serverinimesid.",
"crash_label": "Saada kokkujooksmise aruanded",
"crash_description": "Rakenduse vead saadetakse koos juhusliku ID, 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. Bulwark eemaldab leitud kasutajanimed, aadressid ja sisselogimislubad, kuid kokkujooksmise fail võib siiski sisaldada teie kirjade teksti.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,30 @@
"plugins_off": "سازمان شما افزونه‌ها را در این دستگاه خاموش کرده است.",
"plugins_allowlist": "سازمان شما در این دستگاه فقط برخی افزونه‌ها را مجاز می‌داند.",
"off": "خاموش"
},
"desktopTelemetry": {
"section_title": "اطلاعات استفاده و گزارش‌های خرابی",
"section_description": "هر دو خاموش هستند تا زمانی که روشنشان کنید و هرکدام کلید خودش را دارد.",
"telemetry_label": "ارسال اطلاعات استفاده",
"telemetry_description": "روزی یک بار: یک شناسهٔ تصادفی، نسخهٔ برنامه، نسخهٔ Node، سیستم‌عامل شما، مدت اجرای برنامه و نحوهٔ اتصال آن. با سرور ایمیل شخصی، همچنین قابلیت‌های روشن، تعداد تقریبی حساب‌ها و تعداد افزونه‌ها و پوسته‌ها. هرگز ایمیل، نشانی یا نام سرور ارسال نمی‌شود.",
"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": "گزارش خرابی ارسال نشد."
}
}
+25
View File
@@ -6739,5 +6739,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 : un identifiant aléatoire, la version de l'application, la version de Node, votre système d'exploitation, depuis combien de temps l'application tourne et son mode de connexion. Avec votre propre serveur de messagerie, aussi les fonctions activées, un nombre approximatif de comptes et le nombre de plugins et de thèmes. 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 un identifiant aléatoire, 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. Bulwark retire les noms d'utilisateur, les adresses et les jetons de connexion qu'il trouve, mais un fichier de plantage peut quand même contenir le texte de vos messages.",
"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é."
}
}
+25
View File
@@ -6740,5 +6740,30 @@
"plugins_off": "הארגון שלך כיבה תוספים במכשיר הזה.",
"plugins_allowlist": "הארגון שלך מאפשר במכשיר הזה רק תוספים מסוימים.",
"off": "כבוי"
},
"desktopTelemetry": {
"section_title": "מידע על שימוש ודוחות קריסה",
"section_description": "שניהם כבויים עד שתפעילו אותם, ולכל אחד מתג משלו.",
"telemetry_label": "שליחת מידע על שימוש",
"telemetry_description": "פעם ביום: מזהה אקראי, גרסת האפליקציה, גרסת Node, מערכת ההפעלה, כמה זמן האפליקציה פועלת ואופן החיבור שלה. עם שרת דואר משלכם, גם אילו תכונות פעילות, מספר משוער של חשבונות וכמה תוספים וערכות נושא יש לכם. אף פעם לא דואר, כתובות או שמות שרתים.",
"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": "לא ניתן היה לשלוח את דוח הקריסה."
}
}
+25
View File
@@ -6739,5 +6739,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: egy véletlenszerű azonosító, az alkalmazás verziója, a Node verziója, az operációs rendszer, az alkalmazás futási ideje és a kapcsolódás módja. Saját levelezőszervernél azt is, mely funkciók vannak bekapcsolva, a fiókok hozzávetőleges számát, valamint a bővítmények és témák 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 egy véletlenszerű azonosítóval, 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. A Bulwark eltávolítja a talált felhasználóneveket, címeket és bejelentkezési tokeneket, de az összeomlási fájl így is tartalmazhatja a levelei szövegét.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,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: un ID casuale, la versione dell'app, la versione di Node, il tuo sistema operativo, da quanto tempo è in esecuzione l'app e come si connette. Con un tuo server di posta, anche quali funzioni sono attive, un numero approssimativo di account e quanti plugin e temi hai. Mai email, indirizzi o nomi di server.",
"crash_label": "Invia segnalazioni di arresto anomalo",
"crash_description": "Gli errori dell'app vengono inviati con un ID casuale, 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. Bulwark rimuove i nomi utente, gli indirizzi e i token di accesso che trova, ma un file di arresto può comunque contenere il testo delle tue email.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,30 @@
"plugins_off": "組織はこのデバイスのプラグインをオフにしています。",
"plugins_allowlist": "組織はこのデバイスで特定のプラグインのみを許可しています。",
"off": "オフ"
},
"desktopTelemetry": {
"section_title": "利用情報とクラッシュレポート",
"section_description": "どちらもオンにするまでオフのままで、それぞれに専用のスイッチがあります。",
"telemetry_label": "利用情報を送信",
"telemetry_description": "1日に1回、ランダムなID、アプリのバージョン、Node のバージョン、OS、アプリの稼働時間、接続方法を送信します。自分のメールサーバーを使う場合は、有効な機能、おおよそのアカウント数、プラグインとテーマの数も送信します。メール、アドレス、サーバー名は送信しません。",
"crash_label": "クラッシュレポートを送信",
"crash_description": "アプリのエラーは、ランダムなID、アプリ自身のコードのファイル名、整理された短いメッセージとともに送信されます。アプリ自体がクラッシュした場合、クラッシュファイルはこのコンピューターに残り、あなたが送信したときだけ外に出ます。",
"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": "クラッシュレポートを送信できませんでした。"
}
}
+25
View File
@@ -6739,5 +6739,30 @@
"plugins_off": "조직에서 이 기기의 플러그인을 껐습니다.",
"plugins_allowlist": "조직에서 이 기기에 특정 플러그인만 허용합니다.",
"off": "끔"
},
"desktopTelemetry": {
"section_title": "사용 정보 및 충돌 보고서",
"section_description": "둘 다 켤 때까지 꺼져 있으며, 각각 별도의 스위치가 있습니다.",
"telemetry_label": "사용 정보 보내기",
"telemetry_description": "하루에 한 번: 임의의 ID, 앱 버전, Node 버전, 운영 체제, 앱 실행 시간, 앱의 연결 방식을 보냅니다. 자체 메일 서버를 쓰면 켜진 기능, 대략적인 계정 수, 플러그인과 테마 수도 보냅니다. 메일, 주소, 서버 이름은 절대 보내지 않습니다.",
"crash_label": "충돌 보고서 보내기",
"crash_description": "앱 오류는 임의의 ID, 앱 자체 코드의 파일 이름, 정리된 짧은 메시지와 함께 전송됩니다. 앱 자체가 충돌하면 충돌 파일이 이 컴퓨터에 남고, 직접 보낼 때만 밖으로 나갑니다.",
"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": "충돌 보고서를 보낼 수 없습니다."
}
}
+25
View File
@@ -6739,5 +6739,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ā: nejaušs ID, lietotnes versija, Node versija, jūsu operētājsistēma, cik ilgi lietotne darbojas un kā tā savienojas. Ar savu pasta serveri arī tas, kuras funkcijas ir ieslēgtas, aptuvens kontu skaits un cik spraudņu un motīvu jums ir. 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 nejaušu ID, 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ī. Bulwark noņem atrastos lietotājvārdus, adreses un pierakstīšanās marķierus, bet avārijas failā joprojām var būt jūsu vēstuļu teksts.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,30 @@
"plugins_off": "Танай байгууллага энэ төхөөрөмж дээр залгаасуудыг унтраасан.",
"plugins_allowlist": "Танай байгууллага энэ төхөөрөмж дээр зөвхөн тодорхой залгаасуудыг зөвшөөрдөг.",
"off": "Унтраалттай"
},
"desktopTelemetry": {
"section_title": "Хэрэглээний мэдээлэл ба гэмтлийн тайлан",
"section_description": "Хоёулаа таныг асаах хүртэл унтраалттай бөгөөд тус бүр өөрийн унтраалгатай.",
"telemetry_label": "Хэрэглээний мэдээлэл илгээх",
"telemetry_description": "Өдөрт нэг удаа: санамсаргүй ID, аппын хувилбар, Node-ийн хувилбар, таны үйлдлийн систем, апп хэр удаан ажиллаж байгаа, хэрхэн холбогддог. Өөрийн шуудангийн сервертэй бол мөн ямар боломжууд асаалттай, бүртгэлийн ойролцоо тоо, хэдэн залгаас ба загвартай. Захидал, хаяг, серверийн нэрийг хэзээ ч илгээхгүй.",
"crash_label": "Гэмтлийн тайлан илгээх",
"crash_description": "Аппын алдаанууд санамсаргүй ID, аппын өөрийн кодын файлын нэрс болон богино, цэвэрлэсэн мессежтэй хамт илгээгдэнэ. Апп өөрөө гэмтвэл гэмтлийн файл энэ компьютерт үлдэх бөгөөд та илгээсэн үед л гарна.",
"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": "Гэмтлийн тайланг илгээж чадсангүй."
}
}
+25
View File
@@ -6739,5 +6739,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: en tilfeldig ID, appversjonen, Node-versjonen, operativsystemet ditt, hvor lenge appen har kjørt og hvordan den kobler til. Med egen e-postserver også hvilke funksjoner som er på, et omtrentlig antall kontoer og hvor mange programtillegg og temaer du har. Aldri e-post, adresser eller servernavn.",
"crash_label": "Send krasjrapporter",
"crash_description": "Feil i appen sendes med en tilfeldig ID, 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. Bulwark fjerner brukernavn, adresser og påloggingstokener det finner, men en krasjfil kan likevel inneholde teksten i e-posten din.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,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: een willekeurige ID, de appversie, de Node-versie, je besturingssysteem, hoe lang de app al draait en hoe hij verbinding maakt. Met je eigen mailserver ook welke functies aan staan, een ruw aantal accounts en hoeveel plug-ins en thema's je hebt. Nooit e-mail, adressen of servernamen.",
"crash_label": "Crashrapporten verzenden",
"crash_description": "Fouten in de app gaan mee met een willekeurige ID, 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. Bulwark verwijdert gebruikersnamen, adressen en aanmeldtokens die het vindt, maar een crashbestand kan nog steeds de tekst van je e-mail bevatten.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,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: losowy identyfikator, wersja aplikacji, wersja Node, system operacyjny, jak długo działa aplikacja i jak się łączy. Przy własnym serwerze poczty także włączone funkcje, przybliżona liczba kont oraz liczba wtyczek i motywów. Nigdy poczta, adresy ani nazwy serwerów.",
"crash_label": "Wysyłaj raporty o awariach",
"crash_description": "Błędy aplikacji są wysyłane z losowym identyfikatorem, 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. Bulwark usuwa nazwy użytkowników, adresy i tokeny logowania, które znajdzie, ale plik awarii nadal może zawierać tekst Twojej poczty.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,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: um ID aleatório, a versão da aplicação, a versão do Node, o seu sistema operativo, há quanto tempo a aplicação está a correr e como se liga. Com o seu próprio servidor de correio, também que funções estão ativas, um número aproximado de contas e quantos plugins e temas tem. 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 um ID aleatório, 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. O Bulwark remove os nomes de utilizador, endereços e tokens de início de sessão que encontra, mas um ficheiro de falha pode ainda conter o texto das suas mensagens.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,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: un ID aleatoriu, versiunea aplicației, versiunea Node, sistemul de operare, de cât timp rulează aplicația și cum se conectează. Cu propriul server de e-mail, și ce funcții sunt pornite, un număr aproximativ de conturi și câte pluginuri și teme aveți. Niciodată e-mailuri, adrese sau nume de servere.",
"crash_label": "Trimite rapoarte de blocare",
"crash_description": "Erorile din aplicație pleacă cu un ID aleatoriu, 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. Bulwark elimină numele de utilizator, adresele și tokenurile de autentificare pe care le găsește, dar un fișier de blocare poate conține totuși textul e-mailurilor dvs.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,30 @@
"plugins_off": "Ваша организация отключила плагины на этом устройстве.",
"plugins_allowlist": "Ваша организация разрешает на этом устройстве только некоторые плагины.",
"off": "Выкл."
},
"desktopTelemetry": {
"section_title": "Данные об использовании и отчёты о сбоях",
"section_description": "Оба параметра выключены, пока вы их не включите, и у каждого свой переключатель.",
"telemetry_label": "Отправлять данные об использовании",
"telemetry_description": "Раз в день: случайный идентификатор, версия приложения, версия Node, ваша операционная система, как долго работает приложение и как оно подключается. При собственном почтовом сервере также включённые функции, примерное число учётных записей и количество плагинов и тем. Никогда письма, адреса или имена серверов.",
"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": "Не удалось отправить отчёт о сбое."
}
}
+25
View File
@@ -6739,5 +6739,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: náhodné ID, verzia aplikácie, verzia Node, váš operačný systém, ako dlho aplikácia beží a ako sa pripája. Pri vlastnom poštovom serveri aj zapnuté funkcie, približný počet účtov a počet doplnkov a motívov. 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áhodným ID, 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. Bulwark odstráni používateľské mená, adresy a prihlasovacie tokeny, ktoré nájde, ale súbor o zlyhaní môže aj tak obsahovať text vašej pošty.",
"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ť."
}
}
+25
View File
@@ -6747,5 +6747,30 @@
"plugins_off": "องค์กรของคุณปิดปลั๊กอินบนอุปกรณ์นี้",
"plugins_allowlist": "องค์กรของคุณอนุญาตเฉพาะปลั๊กอินบางตัวบนอุปกรณ์นี้",
"off": "ปิด"
},
"desktopTelemetry": {
"section_title": "ข้อมูลการใช้งานและรายงานข้อขัดข้อง",
"section_description": "ทั้งสองอย่างปิดอยู่จนกว่าคุณจะเปิด และแต่ละอย่างมีสวิตช์ของตัวเอง",
"telemetry_label": "ส่งข้อมูลการใช้งาน",
"telemetry_description": "วันละครั้ง: ID แบบสุ่ม เวอร์ชันแอป เวอร์ชัน Node ระบบปฏิบัติการของคุณ ระยะเวลาที่แอปทำงาน และวิธีที่แอปเชื่อมต่อ หากใช้เซิร์ฟเวอร์อีเมลของคุณเอง จะส่งฟีเจอร์ที่เปิดอยู่ จำนวนบัญชีโดยประมาณ และจำนวนปลั๊กอินกับธีมด้วย ไม่ส่งอีเมล ที่อยู่ หรือชื่อเซิร์ฟเวอร์เด็ดขาด",
"crash_label": "ส่งรายงานข้อขัดข้อง",
"crash_description": "ข้อผิดพลาดในแอปจะถูกส่งพร้อม ID แบบสุ่ม ชื่อไฟล์จากโค้ดของแอปเอง และข้อความสั้นๆ ที่ทำความสะอาดแล้ว หากตัวแอปเองขัดข้อง ไฟล์ข้อขัดข้องจะอยู่บนคอมพิวเตอร์เครื่องนี้และออกไปเมื่อคุณส่งเท่านั้น",
"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": "ไม่สามารถส่งรายงานข้อขัดข้องได้"
}
}
+25
View File
@@ -6739,5 +6739,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: rastgele bir kimlik, uygulama sürümü, Node sürümü, işletim sisteminiz, uygulamanın ne kadar süredir çalıştığı ve nasıl bağlandığı. Kendi posta sunucunuzda ayrıca hangi özelliklerin açık olduğu, yaklaşık hesap sayısı ve kaç eklenti ile temanız olduğu. Asla e-posta, adres veya sunucu adı gönderilmez.",
"crash_label": "Kilitlenme raporlarını gönder",
"crash_description": "Uygulamadaki hatalar rastgele bir kimlik, 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. Bulwark bulduğu kullanıcı adlarını, adresleri ve oturum açma belirteçlerini kaldırır ancak kilitlenme dosyası yine de e-postalarınızın metnini içerebilir.",
"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."
}
}
+25
View File
@@ -6739,5 +6739,30 @@
"plugins_off": "Ваша організація вимкнула плагіни на цьому пристрої.",
"plugins_allowlist": "Ваша організація дозволяє на цьому пристрої лише деякі плагіни.",
"off": "Вимк."
},
"desktopTelemetry": {
"section_title": "Дані про використання та звіти про збої",
"section_description": "Обидва вимкнені, доки ви їх не ввімкнете, і кожен має власний перемикач.",
"telemetry_label": "Надсилати дані про використання",
"telemetry_description": "Раз на день: випадковий ідентифікатор, версія застосунку, версія Node, ваша операційна система, як довго працює застосунок і як він підключається. З власним поштовим сервером також увімкнені функції, приблизна кількість облікових записів і кількість плагінів і тем. Ніколи листи, адреси чи назви серверів.",
"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": "Не вдалося надіслати звіт про збій."
}
}
+25
View File
@@ -6751,5 +6751,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: một ID ngẫu nhiên, phiên bản ứng dụng, phiên bản Node, hệ điều hành của bạn, ứng dụng đã chạy bao lâu và cách nó kết nối. Với máy chủ thư riêng, còn có các tính năng đang bật, số tài khoản ước tính và số tiện ích cùng chủ đề bạn có. 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 một ID ngẫu nhiên, 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ố. Bulwark xóa tên người dùng, địa chỉ và mã đăng nhập mà nó tìm thấy, nhưng tệp sự cố vẫn có thể chứa nội dung thư của bạn.",
"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ố."
}
}
+25
View File
@@ -6739,5 +6739,30 @@
"plugins_off": "您的組織已在此裝置上關閉外掛程式。",
"plugins_allowlist": "您的組織在此裝置上只允許使用部分外掛程式。",
"off": "關閉"
},
"desktopTelemetry": {
"section_title": "使用資訊與當機報告",
"section_description": "兩者在您開啟前都維持關閉,且各有獨立的開關。",
"telemetry_label": "傳送使用資訊",
"telemetry_description": "每天一次:一個隨機 ID、應用程式版本、Node 版本、您的作業系統、應用程式已執行多久以及連線方式。使用自己的郵件伺服器時,還包括已開啟的功能、大致的帳號數量以及外掛和主題的數量。絕不傳送郵件、地址或伺服器名稱。",
"crash_label": "傳送當機報告",
"crash_description": "應用程式中的錯誤會連同一個隨機 ID、應用程式本身程式碼的檔案名稱和經過清理的簡短訊息一起傳送。如果應用程式本身當機,當機檔案會留在這台電腦上,只有您傳送時才會離開。",
"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": "無法傳送當機報告。"
}
}
+25
View File
@@ -6739,5 +6739,30 @@
"plugins_off": "您的组织已在此设备上关闭插件。",
"plugins_allowlist": "您的组织在此设备上只允许使用部分插件。",
"off": "关闭"
},
"desktopTelemetry": {
"section_title": "使用信息和崩溃报告",
"section_description": "两者在您开启之前都处于关闭状态,并且各有独立的开关。",
"telemetry_label": "发送使用信息",
"telemetry_description": "每天一次:一个随机 ID、应用版本、Node 版本、您的操作系统、应用已运行多久以及连接方式。使用自己的邮件服务器时,还包括已开启的功能、大致的帐户数量以及插件和主题的数量。绝不发送邮件、地址或服务器名称。",
"crash_label": "发送崩溃报告",
"crash_description": "应用中的错误会连同一个随机 ID、应用自身代码的文件名和经过清理的简短消息一起发送。如果应用本身崩溃,崩溃文件会留在这台电脑上,只有您发送时才会离开。",
"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": "无法发送崩溃报告。"
}
}
@@ -33,6 +33,46 @@ describe('sign-out with plugins', () => {
expect(after).toHaveBeenCalledWith({ accountId: 'all' });
});
describe('crash files, which can hold the mail of whoever was signed in', () => {
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
const withBridge = (deleteCrashes?: () => Promise<void>) => {
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', privacy: { deleteCrashes } };
};
it('go at sign-out', async () => {
const deleteCrashes = vi.fn().mockResolvedValue(undefined);
withBridge(deleteCrashes);
await useAuthStore.getState().logout();
expect(deleteCrashes).toHaveBeenCalledTimes(1);
});
it('go when all accounts sign out', async () => {
const deleteCrashes = vi.fn().mockResolvedValue(undefined);
withBridge(deleteCrashes);
await useAuthStore.getState().logoutAll();
expect(deleteCrashes).toHaveBeenCalled();
});
it('go when switching to another account, and not before', () => {
const deleteCrashes = vi.fn().mockResolvedValue(undefined);
withBridge(deleteCrashes);
useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' });
expect(deleteCrashes).not.toHaveBeenCalled();
useAuthStore.setState({ activeAccountId: 'a2' });
expect(deleteCrashes).toHaveBeenCalledTimes(1);
});
it('signing out works all the same on a shell without the call, or one that fails', async () => {
withBridge(undefined);
await useAuthStore.getState().logout();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
useAuthStore.setState({ isAuthenticated: true });
withBridge(vi.fn().mockRejectedValue(new Error('gone')));
await useAuthStore.getState().logout();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
});
});
describe('keys in the OS store', () => {
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
const withBridge = (clearGrants?: () => Promise<void>) => {
+4 -1
View File
@@ -18,7 +18,7 @@ import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { clearKeystoreGrants } from '@/lib/desktop/keystore-grants';
import { clearDesktopCrashFiles, clearKeystoreGrants } from '@/lib/desktop/keystore-grants';
import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in';
import { isEmbedded, notifyParent } from '@/lib/iframe-bridge';
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager';
@@ -2542,6 +2542,7 @@ export const useAuthStore = create<AuthState>()(
useSettingsStore.getState().disableSync();
clearKeystoreGrants();
clearDesktopCrashFiles();
void authHooks.onAfterLogout.emit({
accountId: accountId ?? 'all',
});
@@ -2722,6 +2723,7 @@ export const useAuthStore = create<AuthState>()(
}
clearKeystoreGrants();
clearDesktopCrashFiles();
void authHooks.onAfterLogout.emit({
accountId: 'all'
});
@@ -3860,6 +3862,7 @@ useAuthStore.subscribe((state, prev) => {
if (ready && !wasReady) void authHooks.onAuthReady.emit({ accountId: state.activeAccountId });
if (state.activeAccountId && prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) {
clearKeystoreGrants();
clearDesktopCrashFiles();
void authHooks.onAccountSwitch.emit({ accountId: state.activeAccountId, previousAccountId: prev.activeAccountId });
}
});