Feature/protocol handlers

* Added account selection for protocol links when multiple connected accounts are available, including mailto: links
* Added support for handling mailto: links in an already-open PWA/session instead of always opening a new tab
* Added webcal: protocol handling for calendar links
* Added account selection for webcal: links when multiple calendar-capable accounts are connected
* Added an import-or-subscribe choice for detected webcal calendars
* Added protocol handler settings for registering mail and calendar handlers and choosing the open mode
* Added service worker/session coordination for passing protocol requests between browser/PWA contexts
* Added tests and translations for the new protocol handler flows
This commit is contained in:
Lucas Gaitzsch
2026-05-12 20:49:05 +02:00
committed by Linus Rath
parent 8b0e2052cf
commit 3f444a8912
40 changed files with 2514 additions and 55 deletions
+151 -6
View File
@@ -15,6 +15,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -37,6 +38,7 @@ import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { downloadEventICS } from "@/lib/calendar-ics-export"; import { downloadEventICS } from "@/lib/calendar-ics-export";
import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog"; import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
@@ -56,6 +58,8 @@ import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal
import { getUserParticipantId } from "@/lib/calendar-participants"; import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
type PendingScopeAction = type PendingScopeAction =
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean } | { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
@@ -68,9 +72,10 @@ function isRecurringEvent(event: CalendarEvent): boolean {
export default function CalendarPage() { export default function CalendarPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const tWebcalAction = useTranslations("calendar.webcal_action");
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
const { const {
@@ -96,6 +101,10 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false); const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [pendingSubscription, setPendingSubscription] = useState<{ url: string; name: string } | null>(null);
const [showWebcalActionChoice, setShowWebcalActionChoice] = useState(false);
const [pendingWebcalAccountChoice, setPendingWebcalAccountChoice] = useState<ParsedWebcal | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null); const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null); const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined); const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
@@ -156,10 +165,10 @@ export default function CalendarPage() {
if (initialCheckDone && !isAuthenticated && !authLoading) { if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
redirectToLogin(); redirectToLogin();
} else if (client && !supportsCalendar) { } else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
router.push("/"); router.push("/");
} }
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]); }, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@@ -167,6 +176,84 @@ export default function CalendarPage() {
} }
}, [error]); }, [error]);
const getWebcalProtocolAccounts = useCallback(() => {
const connectedClients = useAuthStore.getState().getAllConnectedClients();
return useAccountStore.getState().accounts.filter((account) => {
if (!account.isConnected) return false;
return connectedClients.get(account.id)?.supportsCalendars() === true;
});
}, []);
const openWebcalForAccount = useCallback(async (pending: ParsedWebcal, accountId: string) => {
setIsProtocolAccountSwitching(true);
try {
if (useAuthStore.getState().activeAccountId !== accountId) {
await switchAccount(accountId);
}
setPendingWebcalAccountChoice(null);
setPendingSubscription({
url: pending.subscriptionUrl,
name: pending.suggestedName,
});
setShowWebcalActionChoice(true);
} finally {
setIsProtocolAccountSwitching(false);
}
}, [switchAccount]);
const handleWebcalProtocolRequest = useCallback((pending: ParsedWebcal) => {
const protocolAccounts = getWebcalProtocolAccounts();
if (protocolAccounts.length > 1) {
setPendingWebcalAccountChoice(pending);
return;
}
if (protocolAccounts.length === 0 && !supportsCalendar) {
return;
}
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
if (accountId) {
void openWebcalForAccount(pending, accountId);
return;
}
setPendingSubscription({
url: pending.subscriptionUrl,
name: pending.suggestedName,
});
setShowWebcalActionChoice(true);
}, [activeAccountId, getWebcalProtocolAccounts, openWebcalForAccount, supportsCalendar]);
const closeWebcalActionChoice = useCallback(() => {
setShowWebcalActionChoice(false);
setPendingSubscription(null);
}, []);
const handleImportWebcal = useCallback(() => {
setShowWebcalActionChoice(false);
setShowImportModal(true);
}, []);
const handleSubscribeWebcal = useCallback(() => {
setShowWebcalActionChoice(false);
setShowSubscriptionModal(true);
}, []);
useEffect(() => {
if (!isAuthenticated || !client) return;
const openPendingWebcal = () => {
const pending = consumePendingWebcal();
if (!pending) return;
handleWebcalProtocolRequest(pending);
};
openPendingWebcal();
return subscribeToPendingWebcal(openPendingWebcal);
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
useEffect(() => { useEffect(() => {
if (client && !hasFetched.current) { if (client && !hasFetched.current) {
hasFetched.current = true; hasFetched.current = true;
@@ -955,7 +1042,54 @@ export default function CalendarPage() {
}); });
}, [events, selectedCalendarIds, visibleEvents]); }, [events, selectedCalendarIds, visibleEvents]);
if (!isAuthenticated || !supportsCalendar) return null; const renderWebcalAccountPicker = () => pendingWebcalAccountChoice ? (
<ProtocolAccountPicker
kind="webcal"
operation={pendingWebcalAccountChoice}
accounts={getWebcalProtocolAccounts()}
activeAccountId={activeAccountId}
isSwitching={isProtocolAccountSwitching}
onSelect={(accountId) => void openWebcalForAccount(pendingWebcalAccountChoice, accountId)}
onCancel={() => setPendingWebcalAccountChoice(null)}
/>
) : null;
const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={closeWebcalActionChoice} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
aria-label={tWebcalAction("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{tWebcalAction("title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{tWebcalAction("description", { name: pendingSubscription.name })}</p>
</div>
<div className="px-6 py-4 space-y-3">
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
<span className="text-left">
<span className="block font-medium">{tWebcalAction("import_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
</span>
</Button>
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
<span className="text-left">
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
</span>
</Button>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="ghost" onClick={closeWebcalActionChoice}>{tWebcalAction("cancel")}</Button>
</div>
</div>
</div>
) : null;
if (!isAuthenticated) return null;
if (!supportsCalendar) return renderWebcalAccountPicker();
const renderView = () => { const renderView = () => {
if (isLoading && calendars.length === 0) { if (isLoading && calendars.length === 0) {
@@ -1378,14 +1512,23 @@ export default function CalendarPage() {
<ICalImportModal <ICalImportModal
calendars={calendars} calendars={calendars}
client={client} client={client}
onClose={() => setShowImportModal(false)} initialUrl={pendingSubscription?.url}
onClose={() => {
setShowImportModal(false);
setPendingSubscription(null);
}}
/> />
)} )}
{showSubscriptionModal && client && ( {showSubscriptionModal && client && (
<ICalSubscriptionModal <ICalSubscriptionModal
client={client} client={client}
onClose={() => setShowSubscriptionModal(false)} initialUrl={pendingSubscription?.url}
initialName={pendingSubscription?.name}
onClose={() => {
setShowSubscriptionModal(false);
setPendingSubscription(null);
}}
/> />
)} )}
@@ -1402,6 +1545,8 @@ export default function CalendarPage() {
})()} })()}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
{renderWebcalAccountPicker()}
{renderWebcalActionChoice()}
<RecurrenceScopeDialog <RecurrenceScopeDialog
isOpen={!!pendingScopeAction} isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"} actionType={pendingScopeAction?.type || "edit"}
+4 -1
View File
@@ -5,6 +5,7 @@ import { CalendarAlertProvider } from "@/components/providers/calendar-alert-pro
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider"; import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider"; import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
import { TourProvider } from "@/components/tour/tour-provider"; import { TourProvider } from "@/components/tour/tour-provider";
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
import { locales } from "@/i18n/routing"; import { locales } from "@/i18n/routing";
export default async function LocaleLayout({ export default async function LocaleLayout({
@@ -32,7 +33,9 @@ export default async function LocaleLayout({
<RateLimitToastProvider> <RateLimitToastProvider>
<EmbeddedBridgeProvider> <EmbeddedBridgeProvider>
<TourProvider> <TourProvider>
{children} <ProtocolLaunchHandlerProvider>
{children}
</ProtocolLaunchHandlerProvider>
</TourProvider> </TourProvider>
</EmbeddedBridgeProvider> </EmbeddedBridgeProvider>
</RateLimitToastProvider> </RateLimitToastProvider>
+101 -2
View File
@@ -8,6 +8,7 @@ import { EmailList } from "@/components/email/email-list";
import { EmailViewer } from "@/components/email/email-viewer"; import { EmailViewer } from "@/components/email/email-viewer";
import { EmailComposer } from "@/components/email/email-composer"; import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer"; import type { ComposerDraftData } from "@/components/email/email-composer";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader } from "@/components/layout/mobile-header"; import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types"; import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
@@ -60,6 +61,9 @@ import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { usePluginStore } from "@/stores/plugin-store"; import { usePluginStore } from "@/stores/plugin-store";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
import { emailToReadView } from "@/lib/plugin-projection"; import { emailToReadView } from "@/lib/plugin-projection";
@@ -74,6 +78,7 @@ export default function Home() {
const [composerDraftText, setComposerDraftText] = useState(""); const [composerDraftText, setComposerDraftText] = useState("");
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null); const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const [composerSessionId, setComposerSessionId] = useState(0); const [composerSessionId, setComposerSessionId] = useState(0);
const suppressComposerStateSaveSessionRef = useRef<number | null>(null);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog(); const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
@@ -89,8 +94,10 @@ export default function Home() {
const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null); const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null); const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
useIdentitySync(); useIdentitySync();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
@@ -308,6 +315,13 @@ export default function Home() {
[], [],
); );
const getMailtoProtocolAccounts = useCallback(() => {
const connectedClients = useAuthStore.getState().getAllConnectedClients();
return useAccountStore.getState().accounts.filter((account) =>
account.isConnected && connectedClients.has(account.id)
);
}, []);
// Browser back / forward integration. The restore handler reads the // Browser back / forward integration. The restore handler reads the
// latest values from a ref so we don't have to recreate the callback on // latest values from a ref so we don't have to recreate the callback on
// every render (and so the popstate listener is never stale). // every render (and so the popstate listener is never stale).
@@ -651,6 +665,74 @@ export default function Home() {
} }
}, [initialCheckDone, isAuthenticated, authLoading]); }, [initialCheckDone, isAuthenticated, authLoading]);
const openMailtoDraft = useCallback((pending: ParsedMailto) => {
const body = useSettingsStore.getState().plainTextMode
? pending.body
: plainTextToComposerBody(pending.body);
if (showComposer) {
suppressComposerStateSaveSessionRef.current = composerSessionId;
}
setComposerSessionId((id) => id + 1);
setPendingDraft({
to: pending.to.join(", "),
cc: pending.cc.join(", "),
bcc: pending.bcc.join(", "),
subject: pending.subject,
body,
showCc: pending.cc.length > 0,
showBcc: pending.bcc.length > 0,
selectedIdentityId: null,
subAddressTag: "",
mode: "compose",
draftId: null,
});
setComposerMode("compose");
setShowComposer(true);
if (isMobile) setActiveView("viewer");
}, [composerSessionId, isMobile, setActiveView, showComposer]);
const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => {
setIsProtocolAccountSwitching(true);
try {
if (useAuthStore.getState().activeAccountId !== accountId) {
await switchAccount(accountId);
}
setPendingMailtoAccountChoice(null);
openMailtoDraft(pending);
} finally {
setIsProtocolAccountSwitching(false);
}
}, [openMailtoDraft, switchAccount]);
const handleMailtoProtocolRequest = useCallback((pending: ParsedMailto) => {
const protocolAccounts = getMailtoProtocolAccounts();
if (protocolAccounts.length > 1) {
setPendingMailtoAccountChoice(pending);
return;
}
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
if (accountId) {
void openMailtoForAccount(pending, accountId);
return;
}
openMailtoDraft(pending);
}, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]);
useEffect(() => {
if (!isAuthenticated || !client) return;
const openPendingMailto = () => {
const pending = consumePendingMailto();
if (pending) handleMailtoProtocolRequest(pending);
};
openPendingMailto();
return subscribeToPendingMailto(openPendingMailto);
}, [isAuthenticated, client, handleMailtoProtocolRequest]);
// Fallback fetch for paths that didn't go through login()'s prefetch // Fallback fetch for paths that didn't go through login()'s prefetch
// (notably checkAuth on page refresh). The prefetch in auth-store/login() // (notably checkAuth on page refresh). The prefetch in auth-store/login()
// populates mailboxes before this effect first runs, so on the post-login // populates mailboxes before this effect first runs, so on the post-login
@@ -2373,7 +2455,13 @@ export default function Home() {
} : undefined)} } : undefined)}
initialDraftText={composerDraftText} initialDraftText={composerDraftText}
initialData={pendingDraft} initialData={pendingDraft}
onSaveState={(data) => setPendingDraft(data)} onSaveState={(data) => {
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
suppressComposerStateSaveSessionRef.current = null;
return;
}
setPendingDraft(data);
}}
onSend={async (data) => { onSend={async (data) => {
await handleEmailSend(data); await handleEmailSend(data);
setPendingDraft(null); setPendingDraft(null);
@@ -2528,6 +2616,17 @@ export default function Home() {
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" /> <div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
{pendingMailtoAccountChoice && (
<ProtocolAccountPicker
kind="mailto"
operation={pendingMailtoAccountChoice}
accounts={getMailtoProtocolAccounts()}
activeAccountId={activeAccountId}
isSwitching={isProtocolAccountSwitching}
onSelect={(accountId) => void openMailtoForAccount(pendingMailtoAccountChoice, accountId)}
onCancel={() => setPendingMailtoAccountChoice(null)}
/>
)}
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
<PromptDialog {...promptDialogProps} /> <PromptDialog {...promptDialogProps} />
<TotpReauthDialog /> <TotpReauthDialog />
+8
View File
@@ -26,6 +26,7 @@ import {
Bell, Bell,
Puzzle, Puzzle,
LayoutGrid, LayoutGrid,
Link as LinkIcon,
BookOpen, BookOpen,
PenLine, PenLine,
EyeOff, EyeOff,
@@ -63,6 +64,7 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings
import { NotificationSettings } from '@/components/settings/notification-settings'; import { NotificationSettings } from '@/components/settings/notification-settings';
import { ThemesSettings } from '@/components/settings/themes-settings'; import { ThemesSettings } from '@/components/settings/themes-settings';
import { PluginsSettings } from '@/components/settings/plugins-settings'; import { PluginsSettings } from '@/components/settings/plugins-settings';
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store'; import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store'; import { useEmailStore } from '@/stores/email-store';
import { usePluginStore } from '@/stores/plugin-store'; import { usePluginStore } from '@/stores/plugin-store';
@@ -98,6 +100,7 @@ type Tab =
| 'calendar' | 'calendar'
| 'contacts' | 'contacts'
| 'files' | 'files'
| 'protocol_handlers'
| 'sidebar_apps' | 'sidebar_apps'
| 'about_data' | 'about_data'
| 'themes' | 'themes'
@@ -133,6 +136,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
calendar: Calendar, calendar: Calendar,
contacts: BookUser, contacts: BookUser,
files: HardDrive, files: HardDrive,
protocol_handlers: LinkIcon,
sidebar_apps: PanelLeftClose, sidebar_apps: PanelLeftClose,
about_data: Info, about_data: Info,
themes: Palette, themes: Palette,
@@ -211,6 +215,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
calendar: ['calendar.settings', 'calendar.management'], calendar: ['calendar.settings', 'calendar.management'],
contacts: ['settings.contacts', 'contacts'], contacts: ['settings.contacts', 'contacts'],
files: ['settings.files'], files: ['settings.files'],
protocol_handlers: ['protocol_handlers'],
sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'], sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'],
about_data: ['settings.advanced'], about_data: ['settings.advanced'],
themes: [], themes: [],
@@ -240,6 +245,7 @@ const tabKeywords: Record<Tab, string> = {
calendar: 'event schedule appointment meeting timezone', calendar: 'event schedule appointment meeting timezone',
contacts: 'address book contact', contacts: 'address book contact',
files: 'attachments cloud drive storage upload', files: 'attachments cloud drive storage upload',
protocol_handlers: 'mailto webcal links default app protocol handler',
sidebar_apps: 'apps webview iframe', sidebar_apps: 'apps webview iframe',
about_data: 'export import storage quota privacy backup', about_data: 'export import storage quota privacy backup',
themes: 'custom theme css skin appearance', themes: 'custom theme css skin appearance',
@@ -560,6 +566,7 @@ export default function SettingsPage() {
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' }, { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' }, { id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' }, { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
// Appearance // Appearance
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' }, { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
@@ -666,6 +673,7 @@ export default function SettingsPage() {
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>} {effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>} {effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />} {effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />} {effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
{effectiveActiveTab === 'about_data' && <AboutDataSettings />} {effectiveActiveTab === 'about_data' && <AboutDataSettings />}
{effectiveActiveTab === 'themes' && <ThemesSettings />} {effectiveActiveTab === 'themes' && <ThemesSettings />}
+21 -1
View File
@@ -2,13 +2,26 @@ import type { MetadataRoute } from "next";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type WebAppProtocolHandler = {
protocol: string;
url: string;
};
type ExtendedManifest = MetadataRoute.Manifest & {
protocol_handlers?: WebAppProtocolHandler[];
launch_handler?: {
client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new"
| Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">;
};
};
// Manifest paths must include the deployment subpath - browsers resolve them // Manifest paths must include the deployment subpath - browsers resolve them
// against the document origin, not the manifest's location, and Next.js does // against the document origin, not the manifest's location, and Next.js does
// not auto-prefix string literals inside MetadataRoute payloads. // not auto-prefix string literals inside MetadataRoute payloads.
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, ""); const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
const withBase = (p: string) => `${BASE_PATH}${p}`; const withBase = (p: string) => `${BASE_PATH}${p}`;
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): ExtendedManifest {
const appName = const appName =
process.env.APP_NAME || process.env.APP_NAME ||
process.env.NEXT_PUBLIC_APP_NAME || process.env.NEXT_PUBLIC_APP_NAME ||
@@ -57,5 +70,12 @@ export default function manifest(): MetadataRoute.Manifest {
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" }, { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" }, { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
], ],
protocol_handlers: [
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
],
launch_handler: {
client_mode: ["focus-existing", "navigate-new"],
},
}; };
} }
+8
View File
@@ -0,0 +1,8 @@
import { getTranslations } from "next-intl/server";
import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-client";
export default async function MailtoProtocolPage() {
const t = await getTranslations("protocol_handlers");
return <MailtoProtocolClient openingText={t("opening_mailto")} />;
}
+8
View File
@@ -0,0 +1,8 @@
import { getTranslations } from "next-intl/server";
import { WebcalProtocolClient } from "@/components/protocol/webcal-protocol-client";
export default async function WebcalProtocolPage() {
const t = await getTranslations("protocol_handlers");
return <WebcalProtocolClient openingText={t("opening_webcal")} />;
}
+4 -3
View File
@@ -17,6 +17,7 @@ interface ICalImportModalProps {
calendars: Calendar[]; calendars: Calendar[];
client: IJMAPClient; client: IJMAPClient;
onClose: () => void; onClose: () => void;
initialUrl?: string;
} }
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -25,7 +26,7 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing"; type ImportStep = "select" | "preview" | "importing";
type ImportMode = "file" | "url"; type ImportMode = "file" | "url";
export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) { export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) {
const t = useTranslations("calendar.import"); const t = useTranslations("calendar.import");
const tCal = useTranslations("calendar"); const tCal = useTranslations("calendar");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
@@ -43,8 +44,8 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const [isParsing, setIsParsing] = useState(false); const [isParsing, setIsParsing] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [importMode, setImportMode] = useState<ImportMode>("file"); const [importMode, setImportMode] = useState<ImportMode>(initialUrl ? "url" : "file");
const [urlInput, setUrlInput] = useState(""); const [urlInput, setUrlInput] = useState(initialUrl || "");
const [isFetchingUrl, setIsFetchingUrl] = useState(false); const [isFetchingUrl, setIsFetchingUrl] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
@@ -13,9 +13,11 @@ interface ICalSubscriptionModalProps {
client: IJMAPClient; client: IJMAPClient;
onClose: () => void; onClose: () => void;
editSubscription?: ICalSubscription; editSubscription?: ICalSubscription;
initialUrl?: string;
initialName?: string;
} }
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) { export function ICalSubscriptionModal({ client, onClose, editSubscription, initialUrl, initialName }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription"); const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription); const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
@@ -23,8 +25,8 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICa
const isEdit = !!editSubscription; const isEdit = !!editSubscription;
const [url, setUrl] = useState(editSubscription?.url || ""); const [url, setUrl] = useState(editSubscription?.url || initialUrl || "");
const [name, setName] = useState(editSubscription?.name || ""); const [name, setName] = useState(editSubscription?.name || initialName || "");
const [color, setColor] = useState(editSubscription?.color || "#3b82f6"); const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60); const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -0,0 +1,107 @@
"use client";
import { useEffect } from "react";
import { parseMailto } from "@/lib/protocol-handlers/mailto";
import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
type StandaloneNavigator = Navigator & { standalone?: boolean };
function getProtocolPathPrefix(): string {
const marker = "/protocol/mailto";
const index = window.location.pathname.indexOf(marker);
return index > 0 ? window.location.pathname.slice(0, index) : "";
}
function returnToSourcePage() {
window.close();
window.setTimeout(() => {
if (window.history.length > 1) {
window.history.back();
}
}, 150);
}
function openFallbackAppTab(raw: string): boolean {
const url = `${getProtocolPathPrefix()}/protocol/mailto?url=${encodeURIComponent(raw)}&fallback=1`;
const opened = window.open(url, "_blank");
if (!opened) return false;
opened.opener = null;
return true;
}
function shouldOpenFallbackAppTab(): boolean {
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
return !standalone && window.history.length > 1;
}
async function focusExistingClient() {
if (!("serviceWorker" in navigator)) return;
try {
const registration = await navigator.serviceWorker.ready;
const worker = navigator.serviceWorker.controller ?? registration.active;
worker?.postMessage({ type: "focus-existing-mailto-client" });
} catch {
// Focusing is a progressive enhancement; the composer handoff still works.
}
}
interface MailtoProtocolClientProps {
openingText: string;
}
export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps) {
useEffect(() => {
let cancelled = false;
async function handleMailto() {
const params = new URLSearchParams(window.location.search);
const raw = params.get("url");
const isFallbackAppTab = params.get("fallback") === "1";
const openMode = useSettingsStore.getState().protocolOpenMode;
const parsed = raw ? parseMailto(raw) : null;
if (parsed) {
if (!isFallbackAppTab && openMode === "new-tab") {
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
returnToSourcePage();
return;
}
} else if (!isFallbackAppTab) {
const delivered = await requestOpenMailtoInExistingClient(parsed);
if (cancelled) return;
if (delivered) {
void focusExistingClient();
returnToSourcePage();
return;
}
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
returnToSourcePage();
return;
}
}
savePendingMailto(parsed);
}
window.location.replace(`${getProtocolPathPrefix()}/`);
}
void handleMailto();
return () => {
cancelled = true;
};
}, []);
return (
<main className="flex min-h-screen items-center justify-center">
<p>{openingText}</p>
</main>
);
}
@@ -0,0 +1,161 @@
"use client";
import { Loader2, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { getInitials } from "@/lib/account-utils";
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
import type { AccountEntry } from "@/stores/account-store";
import { cn } from "@/lib/utils";
type ProtocolAccountPickerProps = {
accounts: AccountEntry[];
activeAccountId: string | null;
isSwitching?: boolean;
onSelect: (accountId: string) => void;
onCancel: () => void;
} & (
| { kind: "mailto"; operation?: ParsedMailto }
| { kind: "webcal"; operation?: ParsedWebcal }
);
function getHost(value: string): string {
try {
return new URL(value).hostname;
} catch {
return value;
}
}
export function ProtocolAccountPicker({
kind,
accounts,
activeAccountId,
isSwitching = false,
onSelect,
onCancel,
operation,
}: ProtocolAccountPickerProps) {
const t = useTranslations("protocol_handlers");
const tCommon = useTranslations("common");
const details = operation
? kind === "mailto"
? [
{ label: t("detail_to"), value: operation.to.join(", ") || "-" },
{ label: t("detail_subject"), value: operation.subject || t("detail_no_subject") },
]
: [
{ label: t("detail_calendar"), value: operation.suggestedName },
{ label: t("detail_source"), value: getHost(operation.subscriptionUrl) },
]
: [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onCancel} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
aria-label={t("select_account_title")}
className="relative w-full max-w-md rounded-lg border border-border bg-background shadow-xl animate-in zoom-in-95 duration-200"
>
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h2 className="text-lg font-semibold text-foreground">{t("select_account_title")}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{kind === "mailto" ? t("select_mailto_account") : t("select_webcal_account")}
</p>
</div>
<button
type="button"
onClick={onCancel}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="h-5 w-5" />
</button>
</div>
{details.length > 0 && (
<div className="border-b border-border bg-muted/40 px-5 py-3">
<dl className="space-y-1.5 text-sm">
{details.map((detail) => (
<div key={detail.label} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{detail.label}</dt>
<dd className="truncate text-foreground" title={detail.value}>{detail.value}</dd>
</div>
))}
</dl>
</div>
)}
<div className="max-h-80 overflow-y-auto p-2">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
const initials = getInitials(account.displayName || account.label, account.email || account.username);
let host = account.serverUrl;
try {
host = new URL(account.serverUrl).hostname;
} catch {
// Keep the configured value when it is not an absolute URL.
}
return (
<button
key={account.id}
type="button"
disabled={isSwitching}
onClick={() => onSelect(account.id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
isSwitching && "cursor-wait opacity-70"
)}
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
style={{ backgroundColor: account.avatarColor }}
>
{initials}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{account.displayName || account.label}
</span>
{isActive && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
{t("active_account")}
</span>
)}
</div>
<p className="truncate text-xs text-muted-foreground">{account.email || account.username}</p>
<p className="truncate text-[10px] text-muted-foreground">{host}</p>
</div>
</button>
);
})}
</div>
<div className="flex items-center justify-between border-t border-border px-5 py-3">
{isSwitching ? (
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("switching_account")}
</span>
) : (
<span className="text-xs text-muted-foreground">{t("select_account_note")}</span>
)}
<button
type="button"
onClick={onCancel}
disabled={isSwitching}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
>
{tCommon("cancel")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,133 @@
"use client";
import { useEffect } from "react";
import type { ReactNode } from "react";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { getPathPrefix } from "@/lib/browser-navigation";
import { parseMailto } from "@/lib/protocol-handlers/mailto";
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
import {
listenForMailtoRequests,
notifyPendingMailto,
notifyPendingWebcal,
requestOpenMailtoInExistingClient,
savePendingMailto,
savePendingWebcal,
} from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
type LaunchParams = { targetURL?: string };
type StandaloneNavigator = Navigator & { standalone?: boolean };
declare global {
interface Window {
launchQueue?: {
setConsumer: (consumer: (launchParams: LaunchParams) => void) => void;
};
}
}
function getProtocolLaunch(targetURL: string):
| { kind: "mailto"; raw: string }
| { kind: "webcal"; raw: string }
| null {
let url: URL;
try {
url = new URL(targetURL, window.location.origin);
} catch {
return null;
}
if (url.origin !== window.location.origin) return null;
const raw = url.searchParams.get("url");
if (!raw) return null;
if (url.pathname.includes("/protocol/mailto")) return { kind: "mailto", raw };
if (url.pathname.includes("/protocol/webcal")) return { kind: "webcal", raw };
return null;
}
function isStandaloneDisplayMode() {
return window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
}
function openProtocolInNewTab(protocol: "mailto" | "webcal", raw: string): boolean {
const url = `${getPathPrefix()}/protocol/${protocol}?url=${encodeURIComponent(raw)}&fallback=1`;
const opened = window.open(url, "_blank");
if (!opened) return false;
opened.opener = null;
return true;
}
interface ProtocolLaunchHandlerProviderProps {
children: ReactNode;
}
export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandlerProviderProps) {
const t = useTranslations("protocol_handlers");
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (pathname.startsWith("/protocol/")) return;
return listenForMailtoRequests((pending) => {
savePendingMailto(pending);
notifyPendingMailto();
if (pathname !== "/") router.push("/");
}, () => ({
path: pathname,
standalone: isStandaloneDisplayMode(),
focusNotificationTitle: t("focus_notification_title"),
focusNotificationBody: t("focus_notification_body"),
}));
}, [pathname, router, t]);
useEffect(() => {
if (typeof window === "undefined" || !window.launchQueue) return;
window.launchQueue.setConsumer((launchParams) => {
if (!launchParams.targetURL) return;
const launch = getProtocolLaunch(launchParams.targetURL);
if (!launch) return;
if (launch.kind === "mailto") {
const parsed = parseMailto(launch.raw);
if (!parsed) return;
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
if (openProtocolInNewTab("mailto", launch.raw)) return;
savePendingMailto(parsed);
notifyPendingMailto();
if (pathname !== "/") router.push("/");
return;
}
void requestOpenMailtoInExistingClient(parsed).then((delivered) => {
if (delivered) return;
savePendingMailto(parsed);
notifyPendingMailto();
if (pathname !== "/") router.push("/");
});
return;
}
const parsed = parseWebcal(launch.raw);
if (!parsed) return;
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
if (openProtocolInNewTab("webcal", launch.raw)) return;
}
savePendingWebcal(parsed);
notifyPendingWebcal();
if (pathname !== "/calendar") router.push("/calendar");
});
}, [pathname, router]);
return children;
}
@@ -0,0 +1,73 @@
"use client";
import { useEffect } from "react";
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
import { savePendingWebcal } from "@/lib/protocol-handlers/session";
import { useSettingsStore } from "@/stores/settings-store";
type StandaloneNavigator = Navigator & { standalone?: boolean };
function getProtocolPathPrefix(): string {
const marker = "/protocol/webcal";
const index = window.location.pathname.indexOf(marker);
return index > 0 ? window.location.pathname.slice(0, index) : "";
}
function returnToSourcePage() {
window.close();
window.setTimeout(() => {
if (window.history.length > 1) {
window.history.back();
}
}, 150);
}
function openFallbackAppTab(raw: string): boolean {
const url = `${getProtocolPathPrefix()}/protocol/webcal?url=${encodeURIComponent(raw)}&fallback=1`;
const opened = window.open(url, "_blank");
if (!opened) return false;
opened.opener = null;
return true;
}
function shouldOpenFallbackAppTab(): boolean {
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
return !standalone && window.history.length > 1;
}
interface WebcalProtocolClientProps {
openingText: string;
}
export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps) {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const raw = params.get("url");
const isFallbackAppTab = params.get("fallback") === "1";
if (raw) {
const parsed = parseWebcal(raw);
if (parsed) {
if (!isFallbackAppTab
&& useSettingsStore.getState().protocolOpenMode === "new-tab"
&& shouldOpenFallbackAppTab()
&& openFallbackAppTab(raw)) {
returnToSourcePage();
return;
}
savePendingWebcal(parsed);
}
}
window.location.replace(`${getProtocolPathPrefix()}/calendar`);
}, []);
return (
<main className="flex min-h-screen items-center justify-center">
<p>{openingText}</p>
</main>
);
}
+2 -35
View File
@@ -1,12 +1,10 @@
"use client"; "use client";
import { useState, useCallback } from 'react'; import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { Mail, X } from 'lucide-react'; import { X } from 'lucide-react';
import { getPathPrefix } from '@/lib/browser-navigation';
import { import {
SUPPORTED_SUB_ADDRESS_DELIMITERS, SUPPORTED_SUB_ADDRESS_DELIMITERS,
isSupportedSubAddressDelimiter, isSupportedSubAddressDelimiter,
@@ -18,8 +16,6 @@ const DEFAULT_CUSTOM_DELIMITER = '~';
export function ComposingSettings() { export function ComposingSettings() {
const t = useTranslations('settings.email_behavior'); const t = useTranslations('settings.email_behavior');
const { appName } = useConfig();
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [newKeyword, setNewKeyword] = useState(''); const [newKeyword, setNewKeyword] = useState('');
const { const {
@@ -32,17 +28,6 @@ export function ComposingSettings() {
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const handleSetDefaultMailProgram = useCallback(() => {
try {
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`);
setDefaultMailStatus('success');
}
} catch {
setDefaultMailStatus('error');
}
}, []);
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}> <SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
@@ -168,24 +153,6 @@ export function ComposingSettings() {
</form> </form>
</div> </div>
)} )}
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
<div className="flex flex-col items-end gap-1">
<button
onClick={handleSetDefaultMailProgram}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<Mail className="w-4 h-4" />
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
</button>
{defaultMailStatus === 'success' && (
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
)}
{defaultMailStatus === 'error' && (
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
)}
</div>
</SettingItem>
</SettingsSection> </SettingsSection>
); );
} }
@@ -0,0 +1,108 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { getPathPrefix } from "@/lib/browser-navigation";
import { useSettingsStore } from "@/stores/settings-store";
import type { ProtocolOpenMode } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection, SettingItem, Select } from "./settings-section";
type Protocol = "mailto" | "webcal";
function canRegisterProtocolHandler(): boolean {
return typeof navigator !== "undefined"
&& "registerProtocolHandler" in navigator
&& typeof window !== "undefined"
&& window.isSecureContext;
}
function getProtocolHandlerUrl(protocol: Protocol) {
return `${window.location.origin}${getPathPrefix()}/protocol/${protocol}?url=%s`;
}
function registerProtocolHandler(protocol: Protocol) {
navigator.registerProtocolHandler(
protocol,
getProtocolHandlerUrl(protocol),
);
}
interface ProtocolHandlerSettingsProps {
supportsCalendar: boolean;
}
export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSettingsProps) {
const t = useTranslations("protocol_handlers");
const protocolOpenMode = useSettingsStore((state) => state.protocolOpenMode);
const updateSetting = useSettingsStore((state) => state.updateSetting);
const [supported, setSupported] = useState(false);
useEffect(() => {
setSupported(canRegisterProtocolHandler());
}, []);
const handleOpenModeChange = async (value: string) => {
const openMode = value as ProtocolOpenMode;
if (openMode === "active-session"
&& typeof window !== "undefined"
&& "Notification" in window
&& Notification.permission === "default") {
await Notification.requestPermission();
}
updateSetting("protocolOpenMode", openMode);
};
const handleRegister = (protocol: Protocol) => {
try {
registerProtocolHandler(protocol);
toast.success(protocol === "mailto" ? t("mailto_registered") : t("webcal_registered"));
} catch {
toast.error(t("registration_failed"));
}
};
const renderRegistrationControl = (protocol: Protocol) => {
return (
<Button size="sm" onClick={() => handleRegister(protocol)} disabled={!supported}>
{protocol === "mailto" ? t("register_mailto") : t("register_webcal")}
</Button>
);
};
return (
<SettingsSection title={t("title")} description={t("description")}>
{!supported && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{t("unsupported")}
</div>
)}
<SettingItem label={t("mailto_label")} description={t("mailto_description")}>
{renderRegistrationControl("mailto")}
</SettingItem>
{supportsCalendar && (
<SettingItem label={t("webcal_label")} description={t("webcal_description")}>
{renderRegistrationControl("webcal")}
</SettingItem>
)}
<SettingItem label={t("protocol_open_mode_label")} description={t("protocol_open_mode_description")}>
<Select
value={protocolOpenMode}
onChange={handleOpenModeChange}
options={[
{ value: "new-tab", label: t("protocol_open_mode_new_tab") },
{ value: "active-session", label: t("protocol_open_mode_active_session") },
]}
/>
</SettingItem>
<p className="text-xs text-muted-foreground">{t("browser_note")}</p>
</SettingsSection>
);
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { plainTextToComposerBody } from "../email-composer-utils";
describe("plainTextToComposerBody", () => {
it("returns an empty string for empty input", () => {
expect(plainTextToComposerBody("")).toBe("");
});
it("escapes HTML before building composer paragraphs", () => {
expect(plainTextToComposerBody("<script>alert('x') & \"q\"</script>")).toBe(
"<p>&lt;script&gt;alert(&#39;x&#39;) &amp; &quot;q&quot;&lt;/script&gt;</p>"
);
});
it("normalizes line endings and preserves single line breaks", () => {
expect(plainTextToComposerBody("line1\r\nline2\rline3")).toBe(
"<p>line1<br>line2<br>line3</p>"
);
});
it("splits paragraphs on blank lines", () => {
expect(plainTextToComposerBody("first\n\nsecond\nthird")).toBe(
"<p>first</p><p>second<br>third</p>"
);
});
});
+170
View File
@@ -0,0 +1,170 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseMailto } from "../protocol-handlers/mailto";
import { listenForMailtoRequests } from "../protocol-handlers/session";
import { parseWebcal } from "../protocol-handlers/webcal";
const originalServiceWorkerDescriptor = Object.getOwnPropertyDescriptor(navigator, "serviceWorker");
function installServiceWorkerMock() {
const listeners = new Set<(event: MessageEvent) => void>();
const worker = { postMessage: vi.fn() };
const serviceWorker = {
ready: Promise.resolve({ active: worker }),
controller: worker,
addEventListener: vi.fn((type: string, listener: EventListener) => {
if (type === "message") listeners.add(listener as (event: MessageEvent) => void);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
if (type === "message") listeners.delete(listener as (event: MessageEvent) => void);
}),
};
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: serviceWorker,
});
return {
dispatch(data: unknown) {
listeners.forEach((listener) => listener(new MessageEvent("message", { data })));
},
};
}
afterEach(() => {
vi.restoreAllMocks();
if (originalServiceWorkerDescriptor) {
Object.defineProperty(navigator, "serviceWorker", originalServiceWorkerDescriptor);
return;
}
Reflect.deleteProperty(navigator, "serviceWorker");
});
describe("protocol handlers", () => {
describe("parseMailto", () => {
it("parses a single path recipient", () => {
expect(parseMailto("mailto:alice@example.com")).toEqual({
to: ["alice@example.com"],
cc: [],
bcc: [],
subject: "",
body: "",
});
});
it("parses multiple recipients with subject and body", () => {
expect(parseMailto("mailto:alice@example.com,bob@example.com?subject=Hello&body=Hi")).toMatchObject({
to: ["alice@example.com", "bob@example.com"],
subject: "Hello",
body: "Hi",
});
});
it("parses to, cc, and bcc query recipients", () => {
expect(parseMailto("mailto:?to=alice@example.com&cc=bob@example.com&bcc=eve@example.com")).toMatchObject({
to: ["alice@example.com"],
cc: ["bob@example.com"],
bcc: ["eve@example.com"],
});
});
it("decodes subject and body values", () => {
expect(parseMailto("mailto:alice@example.com?subject=Hello%20World&body=line1%0Aline2")).toMatchObject({
subject: "Hello World",
body: "line1\nline2",
});
});
it("preserves literal plus signs in query values", () => {
expect(parseMailto("mailto:?to=user+tag@example.com&subject=C++&body=a+b")).toMatchObject({
to: ["user+tag@example.com"],
subject: "C++",
body: "a+b",
});
});
it("rejects non-mailto URLs", () => {
expect(parseMailto("https://example.com")).toBeNull();
});
it("allows an empty mailto URL", () => {
expect(parseMailto("mailto:")).toEqual({
to: [],
cc: [],
bcc: [],
subject: "",
body: "",
});
});
it("removes control characters and caps recipients", () => {
const recipients = Array.from({ length: 250 }, (_, index) => `user${index}@example.com`).join(",");
const parsed = parseMailto(`mailto:${recipients}?subject=Hi%0ABcc:evil@example.com`);
expect(parsed?.to).toHaveLength(200);
expect(parsed?.subject).toBe("HiBcc:evil@example.com");
});
});
describe("parseWebcal", () => {
it("normalizes webcal to https", () => {
expect(parseWebcal("webcal://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
});
it("normalizes webcals to https", () => {
expect(parseWebcal("webcals://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
});
it("accepts https URLs", () => {
expect(parseWebcal("https://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
});
it("rejects unsupported protocols", () => {
expect(parseWebcal("ftp://example.com/calendar.ics")).toBeNull();
});
it("suggests a name from the path", () => {
expect(parseWebcal("webcal://example.com/team.ics")?.suggestedName).toBe("team");
});
it("falls back to hostname for suggested name", () => {
expect(parseWebcal("webcal://example.com/")?.suggestedName).toBe("example.com");
});
it("prefers a name query parameter", () => {
expect(parseWebcal("webcal://example.com/team.ics?name=Team%20Calendar")?.suggestedName).toBe("Team Calendar");
});
});
describe("listenForMailtoRequests", () => {
const mailtoValue = {
to: ["alice@example.com"],
cc: [],
bcc: [],
subject: "Hello",
body: "Hi",
};
it("accepts legacy service-worker mailto messages without a client id", () => {
const serviceWorker = installServiceWorkerMock();
const onMailto = vi.fn();
vi.spyOn(window, "focus").mockImplementation(() => undefined);
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
serviceWorker.dispatch({ type: "mailto-request", id: "legacy", value: mailtoValue });
expect(onMailto).toHaveBeenCalledWith(mailtoValue);
cleanup();
});
it("ignores service-worker mailto messages for another client", () => {
const serviceWorker = installServiceWorkerMock();
const onMailto = vi.fn();
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
serviceWorker.dispatch({ type: "mailto-request", id: "targeted", clientId: "other-client", value: mailtoValue });
expect(onMailto).not.toHaveBeenCalled();
cleanup();
});
});
});
+23
View File
@@ -0,0 +1,23 @@
const HTML_ESCAPE_MAP = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
} as const;
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) =>
HTML_ESCAPE_MAP[char as keyof typeof HTML_ESCAPE_MAP]
);
}
export function plainTextToComposerBody(text: string): string {
if (!text) return "";
return text
.replace(/\r\n?/g, "\n")
.split(/\n{2,}/)
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
.join("");
}
+115
View File
@@ -0,0 +1,115 @@
export interface ParsedMailto {
to: string[];
cc: string[];
bcc: string[];
subject: string;
body: string;
}
const MAX_RECIPIENTS = 200;
const MAX_SUBJECT_LENGTH = 998;
const MAX_BODY_LENGTH = 64 * 1024;
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
const CONTROL_CHARS_EXCEPT_LINE_BREAKS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
function stripControlChars(value: string): string {
return value.replace(CONTROL_CHARS, "");
}
function stripBodyControlChars(value: string): string {
return value
.replace(/\r\n?/g, "\n")
.replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, "");
}
function splitRecipients(value: string): string[] {
return stripControlChars(value)
.split(",")
.map((recipient) => recipient.trim())
.filter(Boolean);
}
type QueryParam = {
key: string;
value: string;
};
function getQueryValue(searchParams: QueryParam[], key: string): string {
const values: string[] = [];
const lowerKey = key.toLowerCase();
for (const { key: paramKey, value } of searchParams) {
if (paramKey.toLowerCase() === lowerKey) {
values.push(value);
}
}
return values.join(",");
}
function decodePathname(pathname: string): string | null {
try {
return decodeURIComponent(pathname || "");
} catch {
return null;
}
}
function decodeQueryPart(value: string): string | null {
try {
// RFC 6068 uses percent-encoding for mailto query fields; unlike form
// encoding, a literal '+' is part of the value and must not become space.
return decodeURIComponent(value);
} catch {
return null;
}
}
function parseQuery(query: string): QueryParam[] | null {
if (!query) return [];
const params: QueryParam[] = [];
for (const part of query.split("&")) {
if (!part) continue;
const separatorIndex = part.indexOf("=");
const rawKey = separatorIndex >= 0 ? part.slice(0, separatorIndex) : part;
const rawValue = separatorIndex >= 0 ? part.slice(separatorIndex + 1) : "";
const key = decodeQueryPart(rawKey);
const value = decodeQueryPart(rawValue);
if (key === null || value === null) return null;
params.push({ key, value });
}
return params;
}
export function parseMailto(raw: string): ParsedMailto | null {
if (!raw.toLowerCase().startsWith("mailto:")) return null;
const addressAndQuery = raw.slice("mailto:".length);
const queryIndex = addressAndQuery.indexOf("?");
const rawPathname = queryIndex >= 0 ? addressAndQuery.slice(0, queryIndex) : addressAndQuery;
const rawQuery = queryIndex >= 0 ? addressAndQuery.slice(queryIndex + 1) : "";
const decodedPathname = decodePathname(rawPathname);
if (decodedPathname === null) return null;
const searchParams = parseQuery(rawQuery);
if (searchParams === null) return null;
const to = [
...splitRecipients(decodedPathname),
...splitRecipients(getQueryValue(searchParams, "to")),
].slice(0, MAX_RECIPIENTS);
const remainingAfterTo = Math.max(0, MAX_RECIPIENTS - to.length);
const cc = splitRecipients(getQueryValue(searchParams, "cc")).slice(0, remainingAfterTo);
const remainingAfterCc = Math.max(0, MAX_RECIPIENTS - to.length - cc.length);
const bcc = splitRecipients(getQueryValue(searchParams, "bcc")).slice(0, remainingAfterCc);
return {
to,
cc,
bcc,
subject: stripControlChars(getQueryValue(searchParams, "subject")).slice(0, MAX_SUBJECT_LENGTH),
body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH),
};
}
+357
View File
@@ -0,0 +1,357 @@
import type { ParsedMailto } from "./mailto";
import type { ParsedWebcal } from "./webcal";
const MAILTO_KEY = "bulwark:pending-mailto";
const WEBCAL_KEY = "bulwark:pending-webcal";
const PROTOCOL_CHANNEL = "bulwark:protocol-handlers";
const PENDING_TTL_MS = 5 * 60 * 1000;
const MAILTO_REQUEST = "mailto-request";
const MAILTO_CANDIDATE = "mailto-candidate";
const MAILTO_ACK = "mailto-ack";
const OPEN_MAILTO_IN_CLIENT = "open-mailto-in-client";
const MAILTO_CLIENT_READY = "mailto-client-ready";
const MAILTO_CLIENT_GONE = "mailto-client-gone";
const PENDING_MAILTO_EVENT = "bulwark:pending-mailto";
const PENDING_WEBCAL_EVENT = "bulwark:pending-webcal";
type PendingValue<T> = T & { createdAt: number };
type PendingMailtoRequest = { type: typeof MAILTO_REQUEST; id: string; value: ParsedMailto; clientId?: string };
type PendingMailtoCandidate = { type: typeof MAILTO_CANDIDATE; id: string; clientId: string; priority: number };
type PendingMailtoAck = { type: typeof MAILTO_ACK; id: string };
type OpenMailtoInClientRequest = {
type: typeof OPEN_MAILTO_IN_CLIENT;
id: string;
value: ParsedMailto;
clientId?: string;
};
type ProtocolClientInfo = {
path: string;
standalone: boolean;
clientId?: string;
focusNotificationTitle?: string;
focusNotificationBody?: string;
};
function savePending<T>(key: string, value: T) {
try {
sessionStorage.setItem(key, JSON.stringify({ ...value, createdAt: Date.now() }));
} catch {
// Storage can be unavailable in hardened/private browser modes.
}
}
function consumePending<T>(key: string, validate: (value: unknown) => value is T): T | null {
try {
const raw = sessionStorage.getItem(key);
sessionStorage.removeItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as PendingValue<unknown>;
if (typeof parsed.createdAt !== "number" || Date.now() - parsed.createdAt > PENDING_TTL_MS) {
return null;
}
return validate(parsed) ? parsed : null;
} catch {
return null;
}
}
function hasPending<T>(key: string, validate: (value: unknown) => value is T): boolean {
try {
const raw = sessionStorage.getItem(key);
if (!raw) return false;
const parsed = JSON.parse(raw) as PendingValue<unknown>;
if (typeof parsed.createdAt !== "number" || Date.now() - parsed.createdAt > PENDING_TTL_MS) {
sessionStorage.removeItem(key);
return false;
}
return validate(parsed);
} catch {
return false;
}
}
function isParsedMailto(value: unknown): value is ParsedMailto {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<ParsedMailto>;
return Array.isArray(candidate.to)
&& Array.isArray(candidate.cc)
&& Array.isArray(candidate.bcc)
&& typeof candidate.subject === "string"
&& typeof candidate.body === "string";
}
function isPendingMailtoRequest(value: unknown): value is PendingMailtoRequest {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<PendingMailtoRequest>;
return candidate.type === MAILTO_REQUEST
&& typeof candidate.id === "string"
&& isParsedMailto(candidate.value)
&& (candidate.clientId === undefined || typeof candidate.clientId === "string");
}
function isPendingMailtoAck(value: unknown, id: string): value is PendingMailtoAck {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<PendingMailtoAck>;
return candidate.type === MAILTO_ACK && candidate.id === id;
}
function isPendingMailtoCandidate(value: unknown, id: string): value is PendingMailtoCandidate {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<PendingMailtoCandidate>;
return candidate.type === MAILTO_CANDIDATE
&& candidate.id === id
&& typeof candidate.clientId === "string"
&& typeof candidate.priority === "number";
}
function isOpenMailtoInClientRequest(value: unknown): value is OpenMailtoInClientRequest {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<OpenMailtoInClientRequest>;
return candidate.type === OPEN_MAILTO_IN_CLIENT
&& typeof candidate.id === "string"
&& isParsedMailto(candidate.value)
&& (candidate.clientId === undefined || typeof candidate.clientId === "string");
}
function createRequestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
const BROWSER_CLIENT_ID = createRequestId();
function getMailtoClientPriority(info: ProtocolClientInfo): number {
const isMailSection = info.path === "/" || info.path === "";
if (info.standalone && isMailSection) return 0;
if (isMailSection) return 1;
if (info.standalone) return 2;
return 3;
}
function getDefaultProtocolClientInfo(): ProtocolClientInfo {
const nav = navigator as Navigator & { standalone?: boolean };
const standalone = window.matchMedia?.("(display-mode: standalone)").matches || nav.standalone === true;
return { path: window.location.pathname, standalone, clientId: BROWSER_CLIENT_ID };
}
async function requestMailtoViaServiceWorker(value: ParsedMailto, timeoutMs: number): Promise<boolean> {
if (typeof navigator === "undefined"
|| !("serviceWorker" in navigator)
|| typeof MessageChannel === "undefined") {
return false;
}
try {
const registration = await Promise.race([
navigator.serviceWorker.ready,
new Promise<null>((resolve) => globalThis.setTimeout(() => resolve(null), timeoutMs)),
]);
if (!registration) return false;
const worker = navigator.serviceWorker.controller ?? registration.active;
if (!worker) return false;
return await new Promise((resolve) => {
const channel = new MessageChannel();
const timeout = globalThis.setTimeout(() => {
channel.port1.close();
resolve(false);
}, timeoutMs);
channel.port1.onmessage = (event) => {
globalThis.clearTimeout(timeout);
channel.port1.close();
resolve(event.data?.delivered === true);
};
worker.postMessage({
type: OPEN_MAILTO_IN_CLIENT,
id: createRequestId(),
value,
} satisfies OpenMailtoInClientRequest, [channel.port2]);
});
} catch {
return false;
}
}
function notifyServiceWorker(
type: typeof MAILTO_CLIENT_READY | typeof MAILTO_CLIENT_GONE,
info?: ProtocolClientInfo,
) {
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
navigator.serviceWorker.ready
.then((registration) => {
const worker = navigator.serviceWorker.controller ?? registration.active;
worker?.postMessage({ type, ...info });
})
.catch(() => {
// Service worker registration is optional for local/dev environments.
});
}
function isParsedWebcal(value: unknown): value is ParsedWebcal {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<ParsedWebcal>;
return typeof candidate.originalUrl === "string"
&& typeof candidate.subscriptionUrl === "string"
&& typeof candidate.suggestedName === "string";
}
export function savePendingMailto(value: ParsedMailto) {
savePending(MAILTO_KEY, value);
}
export function consumePendingMailto(): ParsedMailto | null {
return consumePending(MAILTO_KEY, isParsedMailto);
}
export function notifyPendingMailto() {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(PENDING_MAILTO_EVENT));
}
}
export function subscribeToPendingMailto(callback: () => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(PENDING_MAILTO_EVENT, callback);
return () => window.removeEventListener(PENDING_MAILTO_EVENT, callback);
}
async function requestMailtoViaBroadcastChannel(value: ParsedMailto, timeoutMs: number): Promise<boolean> {
if (typeof BroadcastChannel === "undefined") {
return false;
}
return new Promise((resolve) => {
const id = createRequestId();
const channel = new BroadcastChannel(PROTOCOL_CHANNEL);
const candidates: PendingMailtoCandidate[] = [];
let selected = false;
let selectionTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
const candidateWindowMs = Math.min(75, Math.max(25, Math.floor(timeoutMs / 3)));
const timeout = globalThis.setTimeout(() => {
if (selectionTimer) globalThis.clearTimeout(selectionTimer);
channel.close();
resolve(false);
}, timeoutMs);
const selectCandidate = () => {
if (selected) return;
selected = true;
const best = candidates.sort((a, b) => a.priority - b.priority)[0];
if (!best) {
globalThis.clearTimeout(timeout);
channel.close();
resolve(false);
return;
}
channel.postMessage({
type: OPEN_MAILTO_IN_CLIENT,
id,
clientId: best.clientId,
value,
} satisfies OpenMailtoInClientRequest);
};
channel.onmessage = (event) => {
if (isPendingMailtoCandidate(event.data, id)) {
candidates.push(event.data);
selectionTimer ??= globalThis.setTimeout(selectCandidate, candidateWindowMs);
return;
}
if (isPendingMailtoAck(event.data, id)) {
if (selectionTimer) globalThis.clearTimeout(selectionTimer);
globalThis.clearTimeout(timeout);
channel.close();
resolve(true);
}
};
channel.postMessage({ type: MAILTO_REQUEST, id, value } satisfies PendingMailtoRequest);
});
}
export async function requestOpenMailtoInExistingClient(value: ParsedMailto, timeoutMs = 300): Promise<boolean> {
if (await requestMailtoViaServiceWorker(value, timeoutMs)) return true;
return requestMailtoViaBroadcastChannel(value, timeoutMs);
}
export function listenForMailtoRequests(
onMailto: (value: ParsedMailto) => void,
getClientInfo: () => ProtocolClientInfo = getDefaultProtocolClientInfo,
): () => void {
const cleanup: Array<() => void> = [];
const clientInfo = getClientInfo();
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
const handleServiceWorkerMessage = (event: MessageEvent) => {
if (isPendingMailtoRequest(event.data)) {
if (event.data.clientId !== undefined && event.data.clientId !== BROWSER_CLIENT_ID) return;
if (typeof window !== "undefined") window.focus();
onMailto(event.data.value);
}
};
navigator.serviceWorker.addEventListener("message", handleServiceWorkerMessage);
notifyServiceWorker(MAILTO_CLIENT_READY, { ...clientInfo, clientId: BROWSER_CLIENT_ID });
cleanup.push(() => {
notifyServiceWorker(MAILTO_CLIENT_GONE, { ...clientInfo, clientId: BROWSER_CLIENT_ID });
navigator.serviceWorker.removeEventListener("message", handleServiceWorkerMessage);
});
}
if (typeof BroadcastChannel !== "undefined") {
const channel = new BroadcastChannel(PROTOCOL_CHANNEL);
channel.onmessage = (event) => {
if (isPendingMailtoRequest(event.data)) {
channel.postMessage({
type: MAILTO_CANDIDATE,
id: event.data.id,
clientId: BROWSER_CLIENT_ID,
priority: getMailtoClientPriority(getClientInfo()),
} satisfies PendingMailtoCandidate);
return;
}
if (!isOpenMailtoInClientRequest(event.data) || event.data.clientId !== BROWSER_CLIENT_ID) return;
if (typeof window !== "undefined") window.focus();
onMailto(event.data.value);
channel.postMessage({ type: MAILTO_ACK, id: event.data.id } satisfies PendingMailtoAck);
};
cleanup.push(() => channel.close());
}
return () => cleanup.forEach((dispose) => dispose());
}
export function savePendingWebcal(value: ParsedWebcal) {
savePending(WEBCAL_KEY, value);
}
export function consumePendingWebcal(): ParsedWebcal | null {
return consumePending(WEBCAL_KEY, isParsedWebcal);
}
export function notifyPendingWebcal() {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(PENDING_WEBCAL_EVENT));
}
}
export function subscribeToPendingWebcal(callback: () => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(PENDING_WEBCAL_EVENT, callback);
return () => window.removeEventListener(PENDING_WEBCAL_EVENT, callback);
}
export function hasPendingWebcal(): boolean {
return hasPending(WEBCAL_KEY, isParsedWebcal);
}
+48
View File
@@ -0,0 +1,48 @@
export interface ParsedWebcal {
originalUrl: string;
subscriptionUrl: string;
suggestedName: string;
}
function stripControlChars(value: string): string {
return value.replace(/[\u0000-\u001F\u007F]/g, "").trim();
}
function extensionlessName(value: string): string {
return value.replace(/\.(ics|ical)$/i, "");
}
function decodePathSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function parseWebcal(raw: string): ParsedWebcal | null {
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol === "webcal:" || url.protocol === "webcals:") {
url = new URL(raw.replace(/^webcals?:/i, "https:"));
} else if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
const subscriptionUrl = url.toString();
const queryName = stripControlChars(url.searchParams.get("name") || "");
const pathSegment = stripControlChars(decodePathSegment(url.pathname.split("/").filter(Boolean).pop() || ""));
const suggestedName = queryName || extensionlessName(pathSegment) || url.hostname;
return {
originalUrl: raw,
subscriptionUrl,
suggestedName,
};
}
+44
View File
@@ -134,6 +134,40 @@
"nav_label": "Navigace", "nav_label": "Navigace",
"add_app": "Aplikace" "add_app": "Aplikace"
}, },
"protocol_handlers": {
"title": "Výchozí aplikace",
"description": "Zvolte, zda se mají e-mailové a kalendářové odkazy otevírat v Bulwarku. Technicky se Bulwark registruje jako obslužná aplikace protokolu pro odkazy mailto: a webcal:.",
"unsupported": "Tento prohlížeč nebo toto připojení nepodporuje ruční registraci obslužné aplikace protokolu. Nainstalovanou PWA můžete případně použít přes nastavení prohlížeče nebo systému.",
"mailto_label": "E-mailové odkazy",
"mailto_description": "Otevře odkazy mailto: v Bulwarku s předvyplněným editorem zprávy.",
"protocol_open_mode_label": "Při otevírání odkazů protokolů",
"protocol_open_mode_description": "Zvolte, zda má Bulwark otevírat odkazy mailto: a webcal: v nové kartě, nebo znovu použít otevřenou relaci. Volba aktivní relace vyžaduje oprávnění k oznámením, abyste mohli kliknout na záložní oznámení a přenést Bulwark do popředí, pokud prohlížeč blokuje fokus.",
"protocol_open_mode_active_session": "Otevřít v aktivní relaci, pokud je to možné",
"protocol_open_mode_new_tab": "Vždy otevřít novou kartu",
"focus_notification_title": "Otevřít Bulwark",
"focus_notification_body": "Odkaz byl otevřen v Bulwarku. Kliknutím přenesete okno do popředí.",
"webcal_label": "Kalendářové odkazy",
"webcal_description": "Otevře odkazy webcal: v Bulwarku s předvyplněným dialogem pro odběr kalendáře.",
"register_mailto": "Registrovat e-mailovou aplikaci",
"register_webcal": "Registrovat kalendářovou aplikaci",
"mailto_registered": "Registrace obsluhy e-mailových odkazů byla vyžádána",
"webcal_registered": "Registrace obsluhy kalendářových odkazů byla vyžádána",
"registration_failed": "Registrace obslužné aplikace protokolu selhala",
"opening_mailto": "Otevírá se editor...",
"opening_webcal": "Otevírá se kalendář...",
"browser_note": "Prohlížeč nebo operační systém vás může požádat o potvrzení a může vyžadovat, aby byl Bulwark nainstalovaný, než jej půjde vybrat jako výchozí aplikaci.",
"select_account_title": "Vybrat účet",
"select_mailto_account": "Vyberte účet, ve kterém se má tento e-mailový odkaz otevřít.",
"select_webcal_account": "Vyberte účet, ve kterém se má tento kalendářový odkaz otevřít.",
"select_account_note": "Tato volba platí jen pro tento odkaz protokolu.",
"detail_to": "Komu",
"detail_subject": "Předmět",
"detail_no_subject": "Bez předmětu",
"detail_calendar": "Kalendář",
"detail_source": "Zdroj",
"active_account": "Aktivní",
"switching_account": "Přepínání účtu..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "Aplikace postranního panelu", "modal_title": "Aplikace postranního panelu",
"add_new": "Přidat aplikaci", "add_new": "Přidat aplikaci",
@@ -733,6 +767,7 @@
"files": "Soubory", "files": "Soubory",
"contacts": "Kontakty", "contacts": "Kontakty",
"encryption": "Šifrování", "encryption": "Šifrování",
"protocol_handlers": "Výchozí aplikace",
"sidebar_apps": "Aplikace postranního panelu", "sidebar_apps": "Aplikace postranního panelu",
"notifications": "Oznámení", "notifications": "Oznámení",
"layout": "Vzhled", "layout": "Vzhled",
@@ -2431,6 +2466,15 @@
"file_too_large": "Soubor překračuje limit 10 MB", "file_too_large": "Soubor překračuje limit 10 MB",
"invalid_format": "Neplatný formát souboru kalendáře" "invalid_format": "Neplatný formát souboru kalendáře"
}, },
"webcal_action": {
"title": "Otevřít odkaz kalendáře",
"description": "Jak chcete použít \"{name}\"?",
"import_title": "Jednorázově importovat",
"import_description": "Načíst události nyní a zkopírovat je do jednoho z vašich kalendářů.",
"subscribe_title": "Odebírat",
"subscribe_description": "Automaticky synchronizovat tento kalendář jako samostatný kalendář.",
"cancel": "Zrušit"
},
"management": { "management": {
"title": "Správa kalendáře", "title": "Správa kalendáře",
"description": "Vytvářejte, přejmenovávejte a přizpůsobujte si své kalendáře. Klikněte pravým tlačítkem na kalendář v postranním panelu pro rychlou změnu jeho barvy.", "description": "Vytvářejte, přejmenovávejte a přizpůsobujte si své kalendáře. Klikněte pravým tlačítkem na kalendář v postranním panelu pro rychlou změnu jeho barvy.",
+44
View File
@@ -134,6 +134,40 @@
"add_app": "Apps", "add_app": "Apps",
"shared": "Geteilt" "shared": "Geteilt"
}, },
"protocol_handlers": {
"title": "Standard-Apps",
"description": "Legen Sie fest, ob E-Mail- und Kalender-Links in Bulwark geöffnet werden. Technisch registriert sich Bulwark dafür als Protokoll-Handler für mailto: und webcal:.",
"unsupported": "Dieser Browser oder diese Verbindung unterstützt die manuelle Registrierung von Protokoll-Handlern nicht. Möglicherweise können Sie die installierte PWA trotzdem über Browser- oder Systemeinstellungen verwenden.",
"mailto_label": "E-Mail-Links",
"mailto_description": "Öffnet mailto:-Links in Bulwark mit vorausgefülltem Editor.",
"protocol_open_mode_label": "Beim Öffnen von Protokoll-Links",
"protocol_open_mode_description": "Wähle, ob Bulwark mailto:- und webcal:-Links immer in einem neuen Tab öffnet oder eine offene Sitzung wiederverwendet. Für die aktive Sitzung benötigt Bulwark Benachrichtigungen, damit du das Fenster per Klick in den Vordergrund holen kannst, falls der Browser den Fokus blockiert.",
"protocol_open_mode_active_session": "Wenn möglich in aktiver Sitzung öffnen",
"protocol_open_mode_new_tab": "Immer neuen Tab öffnen",
"focus_notification_title": "Bulwark öffnen",
"focus_notification_body": "Der Link wurde in Bulwark geöffnet. Klicke hier, um das Fenster in den Vordergrund zu holen.",
"webcal_label": "Kalender-Links",
"webcal_description": "Öffnet webcal:-Links in Bulwark mit vorausgefülltem Kalender-Abo-Dialog.",
"register_mailto": "Als E-Mail-App registrieren",
"register_webcal": "Als Kalender-App registrieren",
"mailto_registered": "Registrierung als E-Mail-Handler angefordert",
"webcal_registered": "Registrierung als Kalender-Handler angefordert",
"registration_failed": "Protokoll-Handler konnte nicht registriert werden",
"opening_mailto": "Editor wird geöffnet...",
"opening_webcal": "Kalender wird geöffnet...",
"browser_note": "Ihr Browser oder Betriebssystem kann eine Bestätigung verlangen. Eventuell muss Bulwark installiert sein, bevor es als Standard-App ausgewählt werden kann.",
"select_account_title": "Account auswählen",
"select_mailto_account": "Wähle aus, mit welchem Account dieser E-Mail-Link geöffnet werden soll.",
"select_webcal_account": "Wähle aus, mit welchem Account dieser Kalender-Link geöffnet werden soll.",
"select_account_note": "Diese Auswahl gilt nur für diesen Protokoll-Link.",
"detail_to": "An",
"detail_subject": "Betreff",
"detail_no_subject": "Ohne Betreff",
"detail_calendar": "Kalender",
"detail_source": "Quelle",
"active_account": "Aktiv",
"switching_account": "Account wird gewechselt..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "Sidebar-Apps", "modal_title": "Sidebar-Apps",
"add_new": "App hinzufügen", "add_new": "App hinzufügen",
@@ -733,6 +767,7 @@
"encryption": "Verschlüsselung", "encryption": "Verschlüsselung",
"files": "Dateien", "files": "Dateien",
"contacts": "Kontakte", "contacts": "Kontakte",
"protocol_handlers": "Standard-Apps",
"sidebar_apps": "Sidebar-Apps", "sidebar_apps": "Sidebar-Apps",
"notifications": "Benachrichtigungen", "notifications": "Benachrichtigungen",
"layout": "Layout", "layout": "Layout",
@@ -2431,6 +2466,15 @@
"file_too_large": "Datei überschreitet das 10-MB-Limit", "file_too_large": "Datei überschreitet das 10-MB-Limit",
"invalid_format": "Ungültiges Kalenderdateiformat" "invalid_format": "Ungültiges Kalenderdateiformat"
}, },
"webcal_action": {
"title": "Kalender-Link öffnen",
"description": "Wie möchten Sie \"{name}\" verwenden?",
"import_title": "Einmal importieren",
"import_description": "Termine jetzt abrufen und in einen Ihrer Kalender kopieren.",
"subscribe_title": "Abonnieren",
"subscribe_description": "Diesen Kalender automatisch als separaten Kalender synchronisieren.",
"cancel": "Abbrechen"
},
"management": { "management": {
"title": "Kalenderverwaltung", "title": "Kalenderverwaltung",
"description": "Erstellen, umbenennen und anpassen Ihrer Kalender. Rechtsklick auf einen Kalender in der Seitenleiste, um die Farbe schnell zu ändern.", "description": "Erstellen, umbenennen und anpassen Ihrer Kalender. Rechtsklick auf einen Kalender in der Seitenleiste, um die Farbe schnell zu ändern.",
+44
View File
@@ -134,6 +134,40 @@
"nav_label": "Navigation", "nav_label": "Navigation",
"add_app": "Apps" "add_app": "Apps"
}, },
"protocol_handlers": {
"title": "Default apps",
"description": "Choose whether email and calendar links open in Bulwark. Technically, Bulwark registers as a protocol handler for mailto: and webcal: links.",
"unsupported": "This browser or connection does not support manual protocol-handler registration. You may still be able to use the installed PWA via browser or OS settings.",
"mailto_label": "Email links",
"mailto_description": "Open mailto: links in Bulwark with a prefilled composer.",
"protocol_open_mode_label": "When opening protocol links",
"protocol_open_mode_description": "Choose whether Bulwark opens mailto: and webcal: links in a new tab or reuses an open session. The active-session option needs notification permission so you can click a fallback notification to bring Bulwark to the front if the browser blocks focus.",
"protocol_open_mode_active_session": "Open in active session if possible",
"protocol_open_mode_new_tab": "Always open a new tab",
"focus_notification_title": "Open Bulwark",
"focus_notification_body": "The link was opened in Bulwark. Click to bring the window to the front.",
"webcal_label": "Calendar links",
"webcal_description": "Open webcal: links in Bulwark with a prefilled calendar subscription dialog.",
"register_mailto": "Register email app",
"register_webcal": "Register calendar app",
"mailto_registered": "Email handler registration requested",
"webcal_registered": "Calendar handler registration requested",
"registration_failed": "Protocol handler registration failed",
"opening_mailto": "Opening composer...",
"opening_webcal": "Opening calendar...",
"browser_note": "Your browser or operating system may ask you to confirm this and may require Bulwark to be installed before it can be selected as the default app.",
"select_account_title": "Choose account",
"select_mailto_account": "Choose which account should open this email link.",
"select_webcal_account": "Choose which account should open this calendar link.",
"select_account_note": "This only applies to this protocol link.",
"detail_to": "To",
"detail_subject": "Subject",
"detail_no_subject": "No subject",
"detail_calendar": "Calendar",
"detail_source": "Source",
"active_account": "Active",
"switching_account": "Switching account..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "Sidebar Apps", "modal_title": "Sidebar Apps",
"add_new": "Add App", "add_new": "Add App",
@@ -736,6 +770,7 @@
"files": "Files", "files": "Files",
"contacts": "Contacts", "contacts": "Contacts",
"encryption": "Encryption", "encryption": "Encryption",
"protocol_handlers": "Default apps",
"sidebar_apps": "Sidebar Apps", "sidebar_apps": "Sidebar Apps",
"notifications": "Notifications", "notifications": "Notifications",
"layout": "Layout", "layout": "Layout",
@@ -2445,6 +2480,15 @@
"file_too_large": "File exceeds 10MB limit", "file_too_large": "File exceeds 10MB limit",
"invalid_format": "Invalid calendar file format" "invalid_format": "Invalid calendar file format"
}, },
"webcal_action": {
"title": "Open calendar link",
"description": "How would you like to use \"{name}\"?",
"import_title": "Import once",
"import_description": "Fetch the events now and copy them into one of your calendars.",
"subscribe_title": "Subscribe",
"subscribe_description": "Keep this calendar synced automatically as a separate calendar.",
"cancel": "Cancel"
},
"management": { "management": {
"title": "Calendar Management", "title": "Calendar Management",
"description": "Create, rename, and customize your calendars. Right-click a calendar in the sidebar to quickly change its color.", "description": "Create, rename, and customize your calendars. Right-click a calendar in the sidebar to quickly change its color.",
+44
View File
@@ -134,6 +134,40 @@
"add_app": "Apps", "add_app": "Apps",
"shared": "Compartido" "shared": "Compartido"
}, },
"protocol_handlers": {
"title": "Aplicaciones predeterminadas",
"description": "Elige si los enlaces de correo y calendario se abren en Bulwark. Técnicamente, Bulwark se registra como controlador de protocolo para enlaces mailto: y webcal:.",
"unsupported": "Este navegador o esta conexión no admite el registro manual de controladores de protocolo. Es posible que aún puedas usar la PWA instalada desde la configuración del navegador o del sistema.",
"mailto_label": "Enlaces de correo",
"mailto_description": "Abre enlaces mailto: en Bulwark con el redactor rellenado previamente.",
"protocol_open_mode_label": "Al abrir enlaces de protocolo",
"protocol_open_mode_description": "Elige si Bulwark abre los enlaces mailto: y webcal: en una nueva pestaña o reutiliza una sesión abierta. La opción de sesión activa necesita permiso de notificaciones para que puedas hacer clic en una notificación de respaldo y traer Bulwark al frente si el navegador bloquea el foco.",
"protocol_open_mode_active_session": "Abrir en la sesión activa si es posible",
"protocol_open_mode_new_tab": "Abrir siempre una nueva pestaña",
"focus_notification_title": "Abrir Bulwark",
"focus_notification_body": "El enlace se abrió en Bulwark. Haz clic para traer la ventana al frente.",
"webcal_label": "Enlaces de calendario",
"webcal_description": "Abre enlaces webcal: en Bulwark con un diálogo de suscripción al calendario rellenado previamente.",
"register_mailto": "Registrar aplicación de correo",
"register_webcal": "Registrar aplicación de calendario",
"mailto_registered": "Registro del controlador de correo solicitado",
"webcal_registered": "Registro del controlador de calendario solicitado",
"registration_failed": "No se pudo registrar el controlador de protocolo",
"opening_mailto": "Abriendo redactor...",
"opening_webcal": "Abriendo calendario...",
"browser_note": "Tu navegador o sistema operativo puede pedirte confirmación y puede requerir que Bulwark esté instalado antes de poder seleccionarlo como aplicación predeterminada.",
"select_account_title": "Elegir cuenta",
"select_mailto_account": "Elige qué cuenta debe abrir este enlace de correo.",
"select_webcal_account": "Elige qué cuenta debe abrir este enlace de calendario.",
"select_account_note": "Esta selección solo se aplica a este enlace de protocolo.",
"detail_to": "Para",
"detail_subject": "Asunto",
"detail_no_subject": "Sin asunto",
"detail_calendar": "Calendario",
"detail_source": "Fuente",
"active_account": "Activa",
"switching_account": "Cambiando de cuenta..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "Aplicaciones de la barra lateral", "modal_title": "Aplicaciones de la barra lateral",
"add_new": "Añadir aplicación", "add_new": "Añadir aplicación",
@@ -733,6 +767,7 @@
"encryption": "Cifrado", "encryption": "Cifrado",
"files": "Archivos", "files": "Archivos",
"contacts": "Contactos", "contacts": "Contactos",
"protocol_handlers": "Aplicaciones predeterminadas",
"sidebar_apps": "Apps de barra lateral", "sidebar_apps": "Apps de barra lateral",
"notifications": "Notificaciones", "notifications": "Notificaciones",
"layout": "Diseño", "layout": "Diseño",
@@ -2431,6 +2466,15 @@
"file_too_large": "El archivo supera el límite de 10 MB", "file_too_large": "El archivo supera el límite de 10 MB",
"invalid_format": "Formato de archivo de calendario no válido" "invalid_format": "Formato de archivo de calendario no válido"
}, },
"webcal_action": {
"title": "Abrir enlace de calendario",
"description": "¿Cómo quieres usar \"{name}\"?",
"import_title": "Importar una vez",
"import_description": "Obtén los eventos ahora y cópialos en uno de tus calendarios.",
"subscribe_title": "Suscribirse",
"subscribe_description": "Mantén este calendario sincronizado automáticamente como un calendario separado.",
"cancel": "Cancelar"
},
"management": { "management": {
"title": "Gestión de calendarios", "title": "Gestión de calendarios",
"description": "Crea, renombra y personaliza tus calendarios. Haz clic derecho en un calendario en la barra lateral para cambiar su color rápidamente.", "description": "Crea, renombra y personaliza tus calendarios. Haz clic derecho en un calendario en la barra lateral para cambiar su color rápidamente.",
+44
View File
@@ -134,6 +134,40 @@
"add_app": "Apps", "add_app": "Apps",
"shared": "Partagé" "shared": "Partagé"
}, },
"protocol_handlers": {
"title": "Applications par défaut",
"description": "Choisissez si les liens d'e-mail et de calendrier s'ouvrent dans Bulwark. Techniquement, Bulwark s'enregistre comme gestionnaire de protocole pour les liens mailto: et webcal:.",
"unsupported": "Ce navigateur ou cette connexion ne prend pas en charge l'enregistrement manuel des gestionnaires de protocole. Vous pourrez peut-être quand même utiliser la PWA installée via les paramètres du navigateur ou du système.",
"mailto_label": "Liens e-mail",
"mailto_description": "Ouvre les liens mailto: dans Bulwark avec un éditeur prérempli.",
"protocol_open_mode_label": "À louverture des liens de protocole",
"protocol_open_mode_description": "Choisissez si Bulwark ouvre les liens mailto: et webcal: dans un nouvel onglet ou réutilise une session ouverte. Loption de session active nécessite lautorisation des notifications afin que vous puissiez cliquer sur une notification de secours pour ramener Bulwark au premier plan si le navigateur bloque le focus.",
"protocol_open_mode_active_session": "Ouvrir dans la session active si possible",
"protocol_open_mode_new_tab": "Toujours ouvrir un nouvel onglet",
"focus_notification_title": "Ouvrir Bulwark",
"focus_notification_body": "Le lien a été ouvert dans Bulwark. Cliquez pour ramener la fenêtre au premier plan.",
"webcal_label": "Liens de calendrier",
"webcal_description": "Ouvre les liens webcal: dans Bulwark avec une boîte de dialogue d'abonnement au calendrier préremplie.",
"register_mailto": "Enregistrer l'application e-mail",
"register_webcal": "Enregistrer l'application de calendrier",
"mailto_registered": "Enregistrement du gestionnaire d'e-mail demandé",
"webcal_registered": "Enregistrement du gestionnaire de calendrier demandé",
"registration_failed": "Échec de l'enregistrement du gestionnaire de protocole",
"opening_mailto": "Ouverture de l'éditeur...",
"opening_webcal": "Ouverture du calendrier...",
"browser_note": "Votre navigateur ou système d'exploitation peut vous demander de confirmer et peut exiger que Bulwark soit installé avant de pouvoir le sélectionner comme application par défaut.",
"select_account_title": "Choisir un compte",
"select_mailto_account": "Choisissez le compte qui doit ouvrir ce lien e-mail.",
"select_webcal_account": "Choisissez le compte qui doit ouvrir ce lien de calendrier.",
"select_account_note": "Cette sélection s'applique uniquement à ce lien de protocole.",
"detail_to": "À",
"detail_subject": "Objet",
"detail_no_subject": "Sans objet",
"detail_calendar": "Calendrier",
"detail_source": "Source",
"active_account": "Actif",
"switching_account": "Changement de compte..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "Applications de la barre latérale", "modal_title": "Applications de la barre latérale",
"add_new": "Ajouter une application", "add_new": "Ajouter une application",
@@ -733,6 +767,7 @@
"encryption": "Chiffrement", "encryption": "Chiffrement",
"files": "Fichiers", "files": "Fichiers",
"contacts": "Contacts", "contacts": "Contacts",
"protocol_handlers": "Applications par défaut",
"sidebar_apps": "Apps de la barre latérale", "sidebar_apps": "Apps de la barre latérale",
"notifications": "Notifications", "notifications": "Notifications",
"layout": "Mise en page", "layout": "Mise en page",
@@ -2445,6 +2480,15 @@
"file_too_large": "Le fichier dépasse la limite de 10 Mo", "file_too_large": "Le fichier dépasse la limite de 10 Mo",
"invalid_format": "Format de fichier calendrier invalide" "invalid_format": "Format de fichier calendrier invalide"
}, },
"webcal_action": {
"title": "Ouvrir le lien de calendrier",
"description": "Comment souhaitez-vous utiliser \"{name}\" ?",
"import_title": "Importer une fois",
"import_description": "Récupérer les événements maintenant et les copier dans l'un de vos calendriers.",
"subscribe_title": "S'abonner",
"subscribe_description": "Synchroniser automatiquement ce calendrier comme calendrier séparé.",
"cancel": "Annuler"
},
"management": { "management": {
"title": "Gestion des calendriers", "title": "Gestion des calendriers",
"description": "Créez, renommez et personnalisez vos calendriers. Clic droit sur un calendrier dans la barre latérale pour changer rapidement sa couleur.", "description": "Créez, renommez et personnalisez vos calendriers. Clic droit sur un calendrier dans la barre latérale pour changer rapidement sa couleur.",
+44
View File
@@ -134,6 +134,40 @@
"add_app": "App", "add_app": "App",
"shared": "Condiviso" "shared": "Condiviso"
}, },
"protocol_handlers": {
"title": "App predefinite",
"description": "Scegli se i link e-mail e calendario devono aprirsi in Bulwark. Tecnicamente, Bulwark si registra come gestore di protocollo per i link mailto: e webcal:.",
"unsupported": "Questo browser o questa connessione non supporta la registrazione manuale dei gestori di protocollo. Potresti comunque poter usare la PWA installata tramite le impostazioni del browser o del sistema.",
"mailto_label": "Link e-mail",
"mailto_description": "Apre i link mailto: in Bulwark con il compositore precompilato.",
"protocol_open_mode_label": "All'apertura dei link di protocollo",
"protocol_open_mode_description": "Scegli se Bulwark deve aprire i link mailto: e webcal: in una nuova scheda o riutilizzare una sessione aperta. L'opzione sessione attiva richiede l'autorizzazione alle notifiche, così puoi fare clic su una notifica di fallback per portare Bulwark in primo piano se il browser blocca il focus.",
"protocol_open_mode_active_session": "Apri nella sessione attiva se possibile",
"protocol_open_mode_new_tab": "Apri sempre una nuova scheda",
"focus_notification_title": "Apri Bulwark",
"focus_notification_body": "Il link è stato aperto in Bulwark. Fai clic per portare la finestra in primo piano.",
"webcal_label": "Link calendario",
"webcal_description": "Apre i link webcal: in Bulwark con una finestra di dialogo di sottoscrizione al calendario precompilata.",
"register_mailto": "Registra app e-mail",
"register_webcal": "Registra app calendario",
"mailto_registered": "Registrazione del gestore e-mail richiesta",
"webcal_registered": "Registrazione del gestore calendario richiesta",
"registration_failed": "Registrazione del gestore di protocollo non riuscita",
"opening_mailto": "Apertura compositore...",
"opening_webcal": "Apertura calendario...",
"browser_note": "Il browser o il sistema operativo potrebbe chiederti di confermare e potrebbe richiedere che Bulwark sia installato prima di poterlo selezionare come app predefinita.",
"select_account_title": "Scegli account",
"select_mailto_account": "Scegli quale account deve aprire questo link e-mail.",
"select_webcal_account": "Scegli quale account deve aprire questo link calendario.",
"select_account_note": "Questa scelta si applica solo a questo link di protocollo.",
"detail_to": "A",
"detail_subject": "Oggetto",
"detail_no_subject": "Senza oggetto",
"detail_calendar": "Calendario",
"detail_source": "Fonte",
"active_account": "Attivo",
"switching_account": "Cambio account..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "App della barra laterale", "modal_title": "App della barra laterale",
"add_new": "Aggiungi app", "add_new": "Aggiungi app",
@@ -733,6 +767,7 @@
"encryption": "Cifratura", "encryption": "Cifratura",
"files": "File", "files": "File",
"contacts": "Contatti", "contacts": "Contatti",
"protocol_handlers": "App predefinite",
"sidebar_apps": "App nella barra laterale", "sidebar_apps": "App nella barra laterale",
"notifications": "Notifiche", "notifications": "Notifiche",
"layout": "Layout", "layout": "Layout",
@@ -2431,6 +2466,15 @@
"file_too_large": "Il file supera il limite di 10 MB", "file_too_large": "Il file supera il limite di 10 MB",
"invalid_format": "Formato del file calendario non valido" "invalid_format": "Formato del file calendario non valido"
}, },
"webcal_action": {
"title": "Apri link calendario",
"description": "Come vuoi usare \"{name}\"?",
"import_title": "Importa una volta",
"import_description": "Recupera subito gli eventi e copiali in uno dei tuoi calendari.",
"subscribe_title": "Abbonati",
"subscribe_description": "Mantieni questo calendario sincronizzato automaticamente come calendario separato.",
"cancel": "Annulla"
},
"management": { "management": {
"title": "Gestione calendari", "title": "Gestione calendari",
"description": "Crea, rinomina e personalizza i tuoi calendari. Fai clic destro su un calendario nella barra laterale per cambiarne rapidamente il colore.", "description": "Crea, rinomina e personalizza i tuoi calendari. Fai clic destro su un calendario nella barra laterale per cambiarne rapidamente il colore.",
+44
View File
@@ -134,6 +134,40 @@
"add_app": "アプリ", "add_app": "アプリ",
"shared": "共有" "shared": "共有"
}, },
"protocol_handlers": {
"title": "既定のアプリ",
"description": "メールとカレンダーのリンクを Bulwark で開くかどうかを選択します。技術的には、Bulwark は mailto: と webcal: リンクのプロトコル ハンドラーとして登録されます。",
"unsupported": "このブラウザーまたは接続では、プロトコル ハンドラーの手動登録がサポートされていません。インストール済みの PWA は、ブラウザーまたはシステム設定から使用できる場合があります。",
"mailto_label": "メールリンク",
"mailto_description": "mailto: リンクを、入力済みの作成画面で Bulwark に開きます。",
"protocol_open_mode_label": "プロトコルリンクを開くとき",
"protocol_open_mode_description": "Bulwark が mailto: と webcal: のリンクを新しいタブで開くか、開いているセッションを再利用するかを選択します。アクティブなセッションのオプションでは通知の許可が必要です。ブラウザーがフォーカスをブロックした場合に、代替通知をクリックして Bulwark を前面に表示できます。",
"protocol_open_mode_active_session": "可能な場合はアクティブなセッションで開く",
"protocol_open_mode_new_tab": "常に新しいタブを開く",
"focus_notification_title": "Bulwark を開く",
"focus_notification_body": "リンクは Bulwark で開かれました。クリックするとウィンドウを前面に表示します。",
"webcal_label": "カレンダーリンク",
"webcal_description": "webcal: リンクを、入力済みのカレンダー購読ダイアログで Bulwark に開きます。",
"register_mailto": "メールアプリを登録",
"register_webcal": "カレンダーアプリを登録",
"mailto_registered": "メール ハンドラーの登録を要求しました",
"webcal_registered": "カレンダー ハンドラーの登録を要求しました",
"registration_failed": "プロトコル ハンドラーの登録に失敗しました",
"opening_mailto": "作成画面を開いています...",
"opening_webcal": "カレンダーを開いています...",
"browser_note": "ブラウザーまたはオペレーティング システムから確認を求められる場合があります。また、既定のアプリとして選択する前に Bulwark のインストールが必要な場合があります。",
"select_account_title": "アカウントを選択",
"select_mailto_account": "このメールリンクを開くアカウントを選択してください。",
"select_webcal_account": "このカレンダーリンクを開くアカウントを選択してください。",
"select_account_note": "この選択は、このプロトコル リンクにのみ適用されます。",
"detail_to": "宛先",
"detail_subject": "件名",
"detail_no_subject": "件名なし",
"detail_calendar": "カレンダー",
"detail_source": "ソース",
"active_account": "アクティブ",
"switching_account": "アカウントを切り替えています..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "サイドバーアプリ", "modal_title": "サイドバーアプリ",
"add_new": "アプリを追加", "add_new": "アプリを追加",
@@ -733,6 +767,7 @@
"encryption": "暗号化", "encryption": "暗号化",
"files": "ファイル", "files": "ファイル",
"contacts": "連絡先", "contacts": "連絡先",
"protocol_handlers": "既定のアプリ",
"sidebar_apps": "サイドバーアプリ", "sidebar_apps": "サイドバーアプリ",
"notifications": "通知", "notifications": "通知",
"layout": "レイアウト", "layout": "レイアウト",
@@ -2431,6 +2466,15 @@
"file_too_large": "ファイルサイズが10MBを超えています", "file_too_large": "ファイルサイズが10MBを超えています",
"invalid_format": "無効なカレンダーファイル形式" "invalid_format": "無効なカレンダーファイル形式"
}, },
"webcal_action": {
"title": "カレンダーリンクを開く",
"description": "\"{name}\"をどのように使用しますか?",
"import_title": "一度だけインポート",
"import_description": "今すぐ予定を取得し、いずれかのカレンダーにコピーします。",
"subscribe_title": "購読",
"subscribe_description": "このカレンダーを別のカレンダーとして自動的に同期します。",
"cancel": "キャンセル"
},
"management": { "management": {
"title": "カレンダー管理", "title": "カレンダー管理",
"description": "カレンダーの作成、名前変更、カスタマイズができます。サイドバーのカレンダーを右クリックして色を素早く変更できます。", "description": "カレンダーの作成、名前変更、カスタマイズができます。サイドバーのカレンダーを右クリックして色を素早く変更できます。",
+44
View File
@@ -134,6 +134,40 @@
"add_app": "앱", "add_app": "앱",
"shared": "공유됨" "shared": "공유됨"
}, },
"protocol_handlers": {
"title": "기본 앱",
"description": "이메일 및 캘린더 링크를 Bulwark에서 열지 선택하세요. 기술적으로 Bulwark는 mailto: 및 webcal: 링크의 프로토콜 핸들러로 등록됩니다.",
"unsupported": "이 브라우저 또는 연결은 수동 프로토콜 핸들러 등록을 지원하지 않습니다. 설치된 PWA는 브라우저 또는 시스템 설정을 통해 사용할 수 있을 수 있습니다.",
"mailto_label": "이메일 링크",
"mailto_description": "mailto: 링크를 Bulwark의 미리 채워진 작성 창에서 엽니다.",
"protocol_open_mode_label": "프로토콜 링크를 열 때",
"protocol_open_mode_description": "Bulwark가 mailto: 및 webcal: 링크를 새 탭에서 열지, 열린 세션을 재사용할지 선택하세요. 활성 세션 옵션은 브라우저가 포커스를 차단할 때 Bulwark를 앞으로 가져오기 위한 대체 알림을 클릭할 수 있도록 알림 권한이 필요합니다.",
"protocol_open_mode_active_session": "가능하면 활성 세션에서 열기",
"protocol_open_mode_new_tab": "항상 새 탭 열기",
"focus_notification_title": "Bulwark 열기",
"focus_notification_body": "링크가 Bulwark에서 열렸습니다. 창을 앞으로 가져오려면 클릭하세요.",
"webcal_label": "캘린더 링크",
"webcal_description": "webcal: 링크를 Bulwark의 미리 채워진 캘린더 구독 대화상자에서 엽니다.",
"register_mailto": "이메일 앱 등록",
"register_webcal": "캘린더 앱 등록",
"mailto_registered": "이메일 핸들러 등록을 요청했습니다",
"webcal_registered": "캘린더 핸들러 등록을 요청했습니다",
"registration_failed": "프로토콜 핸들러 등록에 실패했습니다",
"opening_mailto": "작성 창을 여는 중...",
"opening_webcal": "캘린더를 여는 중...",
"browser_note": "브라우저 또는 운영 체제에서 확인을 요청할 수 있으며, 기본 앱으로 선택하기 전에 Bulwark 설치가 필요할 수 있습니다.",
"select_account_title": "계정 선택",
"select_mailto_account": "이 이메일 링크를 열 계정을 선택하세요.",
"select_webcal_account": "이 캘린더 링크를 열 계정을 선택하세요.",
"select_account_note": "이 선택은 이 프로토콜 링크에만 적용됩니다.",
"detail_to": "받는 사람",
"detail_subject": "제목",
"detail_no_subject": "제목 없음",
"detail_calendar": "캘린더",
"detail_source": "출처",
"active_account": "활성",
"switching_account": "계정 전환 중..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "사이드바 앱", "modal_title": "사이드바 앱",
"add_new": "앱 추가", "add_new": "앱 추가",
@@ -733,6 +767,7 @@
"files": "파일", "files": "파일",
"contacts": "연락처", "contacts": "연락처",
"encryption": "암호화", "encryption": "암호화",
"protocol_handlers": "기본 앱",
"sidebar_apps": "사이드바 앱", "sidebar_apps": "사이드바 앱",
"notifications": "알림", "notifications": "알림",
"layout": "레이아웃", "layout": "레이아웃",
@@ -2431,6 +2466,15 @@
"file_too_large": "파일이 10MB 제한을 넘었어요", "file_too_large": "파일이 10MB 제한을 넘었어요",
"invalid_format": "캘린더 파일 형식이 올바르지 않아요" "invalid_format": "캘린더 파일 형식이 올바르지 않아요"
}, },
"webcal_action": {
"title": "캘린더 링크 열기",
"description": "\"{name}\"을 어떻게 사용할까요?",
"import_title": "한 번 가져오기",
"import_description": "지금 일정을 가져와 내 캘린더 중 하나에 복사합니다.",
"subscribe_title": "구독",
"subscribe_description": "이 캘린더를 별도의 캘린더로 자동 동기화합니다.",
"cancel": "취소"
},
"management": { "management": {
"title": "캘린더 관리", "title": "캘린더 관리",
"description": "캘린더를 만들고 이름을 바꾸거나 색상을 꾸며보세요. 사이드바에서 캘린더를 우클릭하면 색상을 빠르게 바꿀 수 있어요.", "description": "캘린더를 만들고 이름을 바꾸거나 색상을 꾸며보세요. 사이드바에서 캘린더를 우클릭하면 색상을 빠르게 바꿀 수 있어요.",
+44
View File
@@ -134,6 +134,40 @@
"add_app": "Lietotnes", "add_app": "Lietotnes",
"shared": "Koplietots" "shared": "Koplietots"
}, },
"protocol_handlers": {
"title": "Noklusējuma lietotnes",
"description": "Izvēlieties, vai e-pasta un kalendāra saites atvērt Bulwark. Tehniski Bulwark reģistrējas kā protokola apstrādātājs mailto: un webcal: saitēm.",
"unsupported": "Šī pārlūkprogramma vai savienojums neatbalsta manuālu protokola apstrādātāja reģistrāciju. Iespējams, instalēto PWA joprojām var izmantot pārlūkprogrammas vai sistēmas iestatījumos.",
"mailto_label": "E-pasta saites",
"mailto_description": "Atver mailto: saites Bulwark ar iepriekš aizpildītu ziņojuma redaktoru.",
"protocol_open_mode_label": "Atverot protokola saites",
"protocol_open_mode_description": "Izvēlieties, vai Bulwark atver mailto: un webcal: saites jaunā cilnē vai atkārtoti izmanto atvērtu sesiju. Aktīvās sesijas opcijai nepieciešama paziņojumu atļauja, lai jūs varētu noklikšķināt uz rezerves paziņojuma un izcelt Bulwark priekšplānā, ja pārlūkprogramma bloķē fokusu.",
"protocol_open_mode_active_session": "Ja iespējams, atvērt aktīvajā sesijā",
"protocol_open_mode_new_tab": "Vienmēr atvērt jaunu cilni",
"focus_notification_title": "Atvērt Bulwark",
"focus_notification_body": "Saite tika atvērta Bulwark. Noklikšķiniet, lai izceltu logu priekšplānā.",
"webcal_label": "Kalendāra saites",
"webcal_description": "Atver webcal: saites Bulwark ar iepriekš aizpildītu kalendāra abonēšanas dialogu.",
"register_mailto": "Reģistrēt e-pasta lietotni",
"register_webcal": "Reģistrēt kalendāra lietotni",
"mailto_registered": "E-pasta apstrādātāja reģistrācija pieprasīta",
"webcal_registered": "Kalendāra apstrādātāja reģistrācija pieprasīta",
"registration_failed": "Protokola apstrādātāja reģistrācija neizdevās",
"opening_mailto": "Tiek atvērts redaktors...",
"opening_webcal": "Tiek atvērts kalendārs...",
"browser_note": "Pārlūkprogramma vai operētājsistēma var lūgt apstiprinājumu un var prasīt, lai Bulwark būtu instalēts, pirms to var izvēlēties kā noklusējuma lietotni.",
"select_account_title": "Izvēlieties kontu",
"select_mailto_account": "Izvēlieties, kurā kontā atvērt šo e-pasta saiti.",
"select_webcal_account": "Izvēlieties, kurā kontā atvērt šo kalendāra saiti.",
"select_account_note": "Šī izvēle attiecas tikai uz šo protokola saiti.",
"detail_to": "Kam",
"detail_subject": "Temats",
"detail_no_subject": "Bez temata",
"detail_calendar": "Kalendārs",
"detail_source": "Avots",
"active_account": "Aktīvs",
"switching_account": "Notiek konta pārslēgšana..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "Sānu joslas lietotnes", "modal_title": "Sānu joslas lietotnes",
"add_new": "Pievienot lietotni", "add_new": "Pievienot lietotni",
@@ -733,6 +767,7 @@
"files": "Faili", "files": "Faili",
"contacts": "Kontakti", "contacts": "Kontakti",
"encryption": "Šifrēšana", "encryption": "Šifrēšana",
"protocol_handlers": "Noklusējuma lietotnes",
"sidebar_apps": "Sānu joslas lietotnes", "sidebar_apps": "Sānu joslas lietotnes",
"notifications": "Paziņojumi", "notifications": "Paziņojumi",
"layout": "Izkārtojums", "layout": "Izkārtojums",
@@ -2430,6 +2465,15 @@
"file_too_large": "Fails pārsniedz 10 MB limitu", "file_too_large": "Fails pārsniedz 10 MB limitu",
"invalid_format": "Nederīgs kalendāra faila formāts" "invalid_format": "Nederīgs kalendāra faila formāts"
}, },
"webcal_action": {
"title": "Atvērt kalendāra saiti",
"description": "Kā vēlaties izmantot \"{name}\"?",
"import_title": "Importēt vienreiz",
"import_description": "Ielādēt notikumus tagad un kopēt tos vienā no jūsu kalendāriem.",
"subscribe_title": "Abonēt",
"subscribe_description": "Automātiski sinhronizēt šo kalendāru kā atsevišķu kalendāru.",
"cancel": "Atcelt"
},
"management": { "management": {
"title": "Kalendāru pārvaldība", "title": "Kalendāru pārvaldība",
"description": "Izveidojiet, pārdēvējiet un konfigurējiet kalendārus. Ar labo klikšķi varat mainīt kalendāra krāsu.", "description": "Izveidojiet, pārdēvējiet un konfigurējiet kalendārus. Ar labo klikšķi varat mainīt kalendāra krāsu.",
+44
View File
@@ -134,6 +134,40 @@
"add_app": "Apps", "add_app": "Apps",
"shared": "Gedeeld" "shared": "Gedeeld"
}, },
"protocol_handlers": {
"title": "Standaardapps",
"description": "Kies of e-mail- en kalenderlinks in Bulwark worden geopend. Technisch registreert Bulwark zich als protocolhandler voor mailto:- en webcal:-links.",
"unsupported": "Deze browser of verbinding ondersteunt geen handmatige registratie van protocolhandlers. Mogelijk kun je de geïnstalleerde PWA nog gebruiken via de browser- of systeeminstellingen.",
"mailto_label": "E-maillinks",
"mailto_description": "Opent mailto:-links in Bulwark met een vooraf ingevulde opsteller.",
"protocol_open_mode_label": "Bij het openen van protocollinks",
"protocol_open_mode_description": "Kies of Bulwark mailto:- en webcal:-links in een nieuw tabblad opent of een geopende sessie hergebruikt. Voor de optie actieve sessie is toestemming voor meldingen nodig, zodat je op een fallbackmelding kunt klikken om Bulwark naar voren te halen als de browser focus blokkeert.",
"protocol_open_mode_active_session": "Indien mogelijk openen in actieve sessie",
"protocol_open_mode_new_tab": "Altijd een nieuw tabblad openen",
"focus_notification_title": "Bulwark openen",
"focus_notification_body": "De link is geopend in Bulwark. Klik om het venster naar voren te halen.",
"webcal_label": "Kalenderlinks",
"webcal_description": "Opent webcal:-links in Bulwark met een vooraf ingevuld dialoogvenster voor kalenderabonnementen.",
"register_mailto": "E-mailapp registreren",
"register_webcal": "Kalenderapp registreren",
"mailto_registered": "Registratie van e-mailhandler aangevraagd",
"webcal_registered": "Registratie van kalenderhandler aangevraagd",
"registration_failed": "Registratie van protocolhandler mislukt",
"opening_mailto": "Opsteller wordt geopend...",
"opening_webcal": "Kalender wordt geopend...",
"browser_note": "Je browser of besturingssysteem kan om bevestiging vragen en kan vereisen dat Bulwark is geïnstalleerd voordat het als standaardapp kan worden geselecteerd.",
"select_account_title": "Account kiezen",
"select_mailto_account": "Kies welk account deze e-maillink moet openen.",
"select_webcal_account": "Kies welk account deze kalenderlink moet openen.",
"select_account_note": "Deze keuze geldt alleen voor deze protocol-link.",
"detail_to": "Aan",
"detail_subject": "Onderwerp",
"detail_no_subject": "Geen onderwerp",
"detail_calendar": "Agenda",
"detail_source": "Bron",
"active_account": "Actief",
"switching_account": "Account wisselen..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "Zijbalk-apps", "modal_title": "Zijbalk-apps",
"add_new": "App toevoegen", "add_new": "App toevoegen",
@@ -733,6 +767,7 @@
"encryption": "Versleuteling", "encryption": "Versleuteling",
"files": "Bestanden", "files": "Bestanden",
"contacts": "Contacten", "contacts": "Contacten",
"protocol_handlers": "Standaardapps",
"sidebar_apps": "Zijbalk-apps", "sidebar_apps": "Zijbalk-apps",
"notifications": "Meldingen", "notifications": "Meldingen",
"layout": "Indeling", "layout": "Indeling",
@@ -2431,6 +2466,15 @@
"file_too_large": "Bestand overschrijdt de limiet van 10 MB", "file_too_large": "Bestand overschrijdt de limiet van 10 MB",
"invalid_format": "Ongeldig agendabestandsformaat" "invalid_format": "Ongeldig agendabestandsformaat"
}, },
"webcal_action": {
"title": "Agendalink openen",
"description": "Hoe wilt u \"{name}\" gebruiken?",
"import_title": "Eenmalig importeren",
"import_description": "Haal de afspraken nu op en kopieer ze naar een van uw agenda's.",
"subscribe_title": "Abonneren",
"subscribe_description": "Houd deze agenda automatisch gesynchroniseerd als aparte agenda.",
"cancel": "Annuleren"
},
"management": { "management": {
"title": "Agendabeheer", "title": "Agendabeheer",
"description": "Maak, hernoem en pas uw agenda's aan. Klik met de rechtermuisknop op een agenda in de zijbalk om snel de kleur te wijzigen.", "description": "Maak, hernoem en pas uw agenda's aan. Klik met de rechtermuisknop op een agenda in de zijbalk om snel de kleur te wijzigen.",
+44
View File
@@ -134,6 +134,40 @@
"add_app": "Aplikacje", "add_app": "Aplikacje",
"shared": "Udostępnione" "shared": "Udostępnione"
}, },
"protocol_handlers": {
"title": "Aplikacje domyślne",
"description": "Wybierz, czy linki e-mail i kalendarza mają otwierać się w Bulwark. Technicznie Bulwark rejestruje się jako obsługa protokołu dla linków mailto: i webcal:.",
"unsupported": "Ta przeglądarka lub to połączenie nie obsługuje ręcznej rejestracji obsługi protokołu. Nadal możesz mieć możliwość użycia zainstalowanej aplikacji PWA w ustawieniach przeglądarki lub systemu.",
"mailto_label": "Linki e-mail",
"mailto_description": "Otwiera linki mailto: w Bulwark z wstępnie wypełnionym edytorem wiadomości.",
"protocol_open_mode_label": "Podczas otwierania linków protokołu",
"protocol_open_mode_description": "Wybierz, czy Bulwark ma otwierać linki mailto: i webcal: w nowej karcie, czy ponownie używać otwartej sesji. Opcja aktywnej sesji wymaga uprawnienia do powiadomień, aby można było kliknąć powiadomienie awaryjne i przenieść Bulwark na pierwszy plan, jeśli przeglądarka blokuje fokus.",
"protocol_open_mode_active_session": "Jeśli to możliwe, otwórz w aktywnej sesji",
"protocol_open_mode_new_tab": "Zawsze otwieraj nową kartę",
"focus_notification_title": "Otwórz Bulwark",
"focus_notification_body": "Link został otwarty w Bulwark. Kliknij, aby przenieść okno na pierwszy plan.",
"webcal_label": "Linki kalendarza",
"webcal_description": "Otwiera linki webcal: w Bulwark z wstępnie wypełnionym oknem subskrypcji kalendarza.",
"register_mailto": "Zarejestruj aplikację e-mail",
"register_webcal": "Zarejestruj aplikację kalendarza",
"mailto_registered": "Zażądano rejestracji obsługi e-mail",
"webcal_registered": "Zażądano rejestracji obsługi kalendarza",
"registration_failed": "Rejestracja obsługi protokołu nie powiodła się",
"opening_mailto": "Otwieranie edytora...",
"opening_webcal": "Otwieranie kalendarza...",
"browser_note": "Przeglądarka lub system operacyjny może poprosić o potwierdzenie i może wymagać zainstalowania Bulwark, zanim będzie można wybrać go jako aplikację domyślną.",
"select_account_title": "Wybierz konto",
"select_mailto_account": "Wybierz konto, które ma otworzyć ten link e-mail.",
"select_webcal_account": "Wybierz konto, które ma otworzyć ten link kalendarza.",
"select_account_note": "Ten wybór dotyczy tylko tego linku protokołu.",
"detail_to": "Do",
"detail_subject": "Temat",
"detail_no_subject": "Bez tematu",
"detail_calendar": "Kalendarz",
"detail_source": "Źródło",
"active_account": "Aktywne",
"switching_account": "Przełączanie konta..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "Aplikacje paska bocznego", "modal_title": "Aplikacje paska bocznego",
"add_new": "Dodaj aplikację", "add_new": "Dodaj aplikację",
@@ -733,6 +767,7 @@
"files": "Pliki", "files": "Pliki",
"contacts": "Kontakty", "contacts": "Kontakty",
"encryption": "Szyfrowanie", "encryption": "Szyfrowanie",
"protocol_handlers": "Aplikacje domyślne",
"sidebar_apps": "Aplikacje paska bocznego", "sidebar_apps": "Aplikacje paska bocznego",
"notifications": "Powiadomienia", "notifications": "Powiadomienia",
"layout": "Układ", "layout": "Układ",
@@ -2431,6 +2466,15 @@
"file_too_large": "Plik przekracza limit 10 MB", "file_too_large": "Plik przekracza limit 10 MB",
"invalid_format": "Nieprawidłowy format pliku kalendarza" "invalid_format": "Nieprawidłowy format pliku kalendarza"
}, },
"webcal_action": {
"title": "Otwórz link kalendarza",
"description": "Jak chcesz użyć \"{name}\"?",
"import_title": "Importuj jednorazowo",
"import_description": "Pobierz wydarzenia teraz i skopiuj je do jednego ze swoich kalendarzy.",
"subscribe_title": "Subskrybuj",
"subscribe_description": "Automatycznie synchronizuj ten kalendarz jako oddzielny kalendarz.",
"cancel": "Anuluj"
},
"management": { "management": {
"title": "Zarządzanie kalendarzem", "title": "Zarządzanie kalendarzem",
"description": "Twórz, zmieniaj nazwy i dostosowuj swoje kalendarze. Kliknij prawym przyciskiem kalendarz na pasku bocznym, aby szybko zmienić jego kolor.", "description": "Twórz, zmieniaj nazwy i dostosowuj swoje kalendarze. Kliknij prawym przyciskiem kalendarz na pasku bocznym, aby szybko zmienić jego kolor.",
+44
View File
@@ -134,6 +134,40 @@
"add_app": "Apps", "add_app": "Apps",
"shared": "Compartilhado" "shared": "Compartilhado"
}, },
"protocol_handlers": {
"title": "Aplicativos padrão",
"description": "Escolha se links de e-mail e calendário devem abrir no Bulwark. Tecnicamente, o Bulwark se registra como manipulador de protocolo para links mailto: e webcal:.",
"unsupported": "Este navegador ou esta conexão não oferece suporte ao registro manual de manipuladores de protocolo. Talvez você ainda consiga usar o PWA instalado pelas configurações do navegador ou do sistema.",
"mailto_label": "Links de e-mail",
"mailto_description": "Abre links mailto: no Bulwark com o editor preenchido previamente.",
"protocol_open_mode_label": "Ao abrir links de protocolo",
"protocol_open_mode_description": "Escolha se o Bulwark abre links mailto: e webcal: em uma nova guia ou reutiliza uma sessão aberta. A opção de sessão ativa precisa da permissão de notificações para que você possa clicar em uma notificação alternativa e trazer o Bulwark para a frente se o navegador bloquear o foco.",
"protocol_open_mode_active_session": "Abrir na sessão ativa se possível",
"protocol_open_mode_new_tab": "Sempre abrir uma nova guia",
"focus_notification_title": "Abrir Bulwark",
"focus_notification_body": "O link foi aberto no Bulwark. Clique para trazer a janela para a frente.",
"webcal_label": "Links de calendário",
"webcal_description": "Abre links webcal: no Bulwark com uma janela de assinatura de calendário preenchida previamente.",
"register_mailto": "Registrar aplicativo de e-mail",
"register_webcal": "Registrar aplicativo de calendário",
"mailto_registered": "Registro do manipulador de e-mail solicitado",
"webcal_registered": "Registro do manipulador de calendário solicitado",
"registration_failed": "Falha ao registrar manipulador de protocolo",
"opening_mailto": "Abrindo editor...",
"opening_webcal": "Abrindo calendário...",
"browser_note": "Seu navegador ou sistema operacional pode pedir confirmação e pode exigir que o Bulwark esteja instalado antes de poder ser selecionado como aplicativo padrão.",
"select_account_title": "Escolher conta",
"select_mailto_account": "Escolha qual conta deve abrir este link de e-mail.",
"select_webcal_account": "Escolha qual conta deve abrir este link de calendário.",
"select_account_note": "Esta escolha se aplica apenas a este link de protocolo.",
"detail_to": "Para",
"detail_subject": "Assunto",
"detail_no_subject": "Sem assunto",
"detail_calendar": "Calendário",
"detail_source": "Fonte",
"active_account": "Ativa",
"switching_account": "Alternando conta..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "Apps da barra lateral", "modal_title": "Apps da barra lateral",
"add_new": "Adicionar app", "add_new": "Adicionar app",
@@ -733,6 +767,7 @@
"encryption": "Criptografia", "encryption": "Criptografia",
"files": "Arquivos", "files": "Arquivos",
"contacts": "Contatos", "contacts": "Contatos",
"protocol_handlers": "Aplicativos padrão",
"sidebar_apps": "Apps da barra lateral", "sidebar_apps": "Apps da barra lateral",
"notifications": "Notificações", "notifications": "Notificações",
"layout": "Layout", "layout": "Layout",
@@ -2445,6 +2480,15 @@
"file_too_large": "Arquivo excede o limite de 10 MB", "file_too_large": "Arquivo excede o limite de 10 MB",
"invalid_format": "Formato de arquivo de calendário inválido" "invalid_format": "Formato de arquivo de calendário inválido"
}, },
"webcal_action": {
"title": "Abrir link de calendário",
"description": "Como você gostaria de usar \"{name}\"?",
"import_title": "Importar uma vez",
"import_description": "Busque os eventos agora e copie-os para um dos seus calendários.",
"subscribe_title": "Assinar",
"subscribe_description": "Mantenha este calendário sincronizado automaticamente como um calendário separado.",
"cancel": "Cancelar"
},
"management": { "management": {
"title": "Gerenciamento de calendários", "title": "Gerenciamento de calendários",
"description": "Crie, renomeie e personalize seus calendários. Clique com o botão direito em um calendário na barra lateral para alterar rapidamente sua cor.", "description": "Crie, renomeie e personalize seus calendários. Clique com o botão direito em um calendário na barra lateral para alterar rapidamente sua cor.",
+44
View File
@@ -134,6 +134,40 @@
"add_app": "Приложения", "add_app": "Приложения",
"shared": "Общие" "shared": "Общие"
}, },
"protocol_handlers": {
"title": "Приложения по умолчанию",
"description": "Выберите, должны ли ссылки электронной почты и календаря открываться в Bulwark. Технически Bulwark регистрируется как обработчик протокола для ссылок mailto: и webcal:.",
"unsupported": "Этот браузер или это соединение не поддерживает ручную регистрацию обработчиков протоколов. Возможно, установленное PWA всё же можно использовать через настройки браузера или системы.",
"mailto_label": "Ссылки электронной почты",
"mailto_description": "Открывает ссылки mailto: в Bulwark с предварительно заполненным редактором письма.",
"protocol_open_mode_label": "При открытии ссылок протоколов",
"protocol_open_mode_description": "Выберите, будет ли Bulwark открывать ссылки mailto: и webcal: в новой вкладке или повторно использовать открытую сессию. Для варианта с активной сессией нужно разрешение на уведомления, чтобы можно было нажать на резервное уведомление и вывести Bulwark на передний план, если браузер блокирует фокус.",
"protocol_open_mode_active_session": "Открывать в активной сессии, если возможно",
"protocol_open_mode_new_tab": "Всегда открывать новую вкладку",
"focus_notification_title": "Открыть Bulwark",
"focus_notification_body": "Ссылка была открыта в Bulwark. Нажмите, чтобы вывести окно на передний план.",
"webcal_label": "Ссылки календаря",
"webcal_description": "Открывает ссылки webcal: в Bulwark с предварительно заполненным диалогом подписки на календарь.",
"register_mailto": "Зарегистрировать почтовое приложение",
"register_webcal": "Зарегистрировать приложение календаря",
"mailto_registered": "Запрошена регистрация обработчика электронной почты",
"webcal_registered": "Запрошена регистрация обработчика календаря",
"registration_failed": "Не удалось зарегистрировать обработчик протокола",
"opening_mailto": "Открытие редактора...",
"opening_webcal": "Открытие календаря...",
"browser_note": "Браузер или операционная система может запросить подтверждение и может потребовать, чтобы Bulwark был установлен, прежде чем его можно будет выбрать приложением по умолчанию.",
"select_account_title": "Выберите аккаунт",
"select_mailto_account": "Выберите, в каком аккаунте открыть эту ссылку электронной почты.",
"select_webcal_account": "Выберите, в каком аккаунте открыть эту ссылку календаря.",
"select_account_note": "Этот выбор применяется только к этой ссылке протокола.",
"detail_to": "Кому",
"detail_subject": "Тема",
"detail_no_subject": "Без темы",
"detail_calendar": "Календарь",
"detail_source": "Источник",
"active_account": "Активен",
"switching_account": "Переключение аккаунта..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "Приложения боковой панели", "modal_title": "Приложения боковой панели",
"add_new": "Добавить приложение", "add_new": "Добавить приложение",
@@ -733,6 +767,7 @@
"files": "Файлы", "files": "Файлы",
"contacts": "Контакты", "contacts": "Контакты",
"encryption": "Шифрование", "encryption": "Шифрование",
"protocol_handlers": "Приложения по умолчанию",
"sidebar_apps": "Приложения боковой панели", "sidebar_apps": "Приложения боковой панели",
"notifications": "Уведомления", "notifications": "Уведомления",
"layout": "Макет", "layout": "Макет",
@@ -2431,6 +2466,15 @@
"file_too_large": "Файл превышает лимит 10 МБ", "file_too_large": "Файл превышает лимит 10 МБ",
"invalid_format": "Неверный формат файла календаря" "invalid_format": "Неверный формат файла календаря"
}, },
"webcal_action": {
"title": "Открыть ссылку календаря",
"description": "Как вы хотите использовать \"{name}\"?",
"import_title": "Импортировать один раз",
"import_description": "Загрузить события сейчас и скопировать их в один из ваших календарей.",
"subscribe_title": "Подписаться",
"subscribe_description": "Автоматически синхронизировать этот календарь как отдельный календарь.",
"cancel": "Отмена"
},
"management": { "management": {
"title": "Управление календарями", "title": "Управление календарями",
"description": "Создавайте, переименовывайте и настраивайте свои календари. Щёлкните правой кнопкой мыши на календаре в боковой панели для быстрой смены цвета.", "description": "Создавайте, переименовывайте и настраивайте свои календари. Щёлкните правой кнопкой мыши на календаре в боковой панели для быстрой смены цвета.",
+44
View File
@@ -134,6 +134,40 @@
"nav_label": "Gezinme", "nav_label": "Gezinme",
"add_app": "Uygulamalar" "add_app": "Uygulamalar"
}, },
"protocol_handlers": {
"title": "Varsayılan uygulamalar",
"description": "E-posta ve takvim bağlantılarının Bulwark'ta açılıp açılmayacağını seçin. Teknik olarak Bulwark, mailto: ve webcal: bağlantıları için protokol işleyicisi olarak kaydolur.",
"unsupported": "Bu tarayıcı veya bağlantı manuel protokol işleyicisi kaydını desteklemiyor. Yüklü PWA'yı yine de tarayıcı veya işletim sistemi ayarlarından kullanabilirsiniz.",
"mailto_label": "E-posta bağlantıları",
"mailto_description": "mailto: bağlantılarını Bulwark'ta önceden doldurulmuş düzenleyiciyle açın.",
"protocol_open_mode_label": "Protokol bağlantıları açılırken",
"protocol_open_mode_description": "Bulwark'ın mailto: ve webcal: bağlantılarını yeni bir sekmede açmasını mı yoksa açık bir oturumu yeniden kullanmasını mı istediğinizi seçin. Etkin oturum seçeneği, tarayıcı odağı engellerse Bulwark'ı öne getirmek için yedek bildirime tıklayabilmeniz amacıyla bildirim izni gerektirir.",
"protocol_open_mode_active_session": "Mümkünse etkin oturumda aç",
"protocol_open_mode_new_tab": "Her zaman yeni sekme aç",
"focus_notification_title": "Bulwark'ı aç",
"focus_notification_body": "Bağlantı Bulwark'ta açıldı. Pencereyi öne getirmek için tıklayın.",
"webcal_label": "Takvim bağlantıları",
"webcal_description": "webcal: bağlantılarını Bulwark'ta önceden doldurulmuş takvim aboneliği penceresiyle açın.",
"register_mailto": "E-posta uygulaması olarak kaydet",
"register_webcal": "Takvim uygulaması olarak kaydet",
"mailto_registered": "E-posta işleyicisi kaydı istendi",
"webcal_registered": "Takvim işleyicisi kaydı istendi",
"registration_failed": "Protokol işleyicisi kaydı başarısız oldu",
"opening_mailto": "Düzenleyici açılıyor...",
"opening_webcal": "Takvim açılıyor...",
"browser_note": "Tarayıcınız veya işletim sisteminiz bunu onaylamanızı isteyebilir ve Bulwark'ın varsayılan uygulama olarak seçilebilmesi için yüklenmiş olmasını gerektirebilir.",
"select_account_title": "Hesap seç",
"select_mailto_account": "Bu e-posta bağlantısını hangi hesabın açacağını seçin.",
"select_webcal_account": "Bu takvim bağlantısını hangi hesabın açacağını seçin.",
"select_account_note": "Bu seçim yalnızca bu protokol bağlantısı için geçerlidir.",
"detail_to": "Kime",
"detail_subject": "Konu",
"detail_no_subject": "Konu yok",
"detail_calendar": "Takvim",
"detail_source": "Kaynak",
"active_account": "Etkin",
"switching_account": "Hesap değiştiriliyor..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "Kenar Çubuğu Uygulamaları", "modal_title": "Kenar Çubuğu Uygulamaları",
"add_new": "Uygulama Ekle", "add_new": "Uygulama Ekle",
@@ -734,6 +768,7 @@
"contacts": "Kişiler", "contacts": "Kişiler",
"encryption": "Şifreleme", "encryption": "Şifreleme",
"sidebar_apps": "Kenar Çubuğu Uygulamaları", "sidebar_apps": "Kenar Çubuğu Uygulamaları",
"protocol_handlers": "Varsayılan uygulamalar",
"notifications": "Bildirimler", "notifications": "Bildirimler",
"layout": "Düzen", "layout": "Düzen",
"reading": "Okuma", "reading": "Okuma",
@@ -2445,6 +2480,15 @@
"file_too_large": "Dosya 10 MB sınırını aşıyor", "file_too_large": "Dosya 10 MB sınırını aşıyor",
"invalid_format": "Geçersiz takvim dosyası biçimi" "invalid_format": "Geçersiz takvim dosyası biçimi"
}, },
"webcal_action": {
"title": "Takvim bağlantısını aç",
"description": "\"{name}\" öğesini nasıl kullanmak istersiniz?",
"import_title": "Bir kez içe aktar",
"import_description": "Etkinlikleri şimdi alıp takvimlerinizden birine kopyalayın.",
"subscribe_title": "Abone ol",
"subscribe_description": "Bu takvimi ayrı bir takvim olarak otomatik eşitlenmiş tutun.",
"cancel": "İptal"
},
"management": { "management": {
"title": "Takvim Yönetimi", "title": "Takvim Yönetimi",
"description": "Takvimlerinizi oluşturun, yeniden adlandırın ve özelleştirin. Rengini hızlıca değiştirmek için kenar çubuğundaki bir takvime sağ tıklayın.", "description": "Takvimlerinizi oluşturun, yeniden adlandırın ve özelleştirin. Rengini hızlıca değiştirmek için kenar çubuğundaki bir takvime sağ tıklayın.",
+44
View File
@@ -134,6 +134,40 @@
"add_app": "програми", "add_app": "програми",
"shared": "Спільні" "shared": "Спільні"
}, },
"protocol_handlers": {
"title": "Програми за замовчуванням",
"description": "Виберіть, чи відкривати посилання електронної пошти та календаря в Bulwark. Технічно Bulwark реєструється як обробник протоколу для посилань mailto: і webcal:.",
"unsupported": "Цей браузер або це з'єднання не підтримує ручну реєстрацію обробників протоколів. Можливо, встановлену PWA все одно можна використати через налаштування браузера або системи.",
"mailto_label": "Посилання електронної пошти",
"mailto_description": "Відкриває посилання mailto: у Bulwark із попередньо заповненим редактором листа.",
"protocol_open_mode_label": "Під час відкриття посилань протоколів",
"protocol_open_mode_description": "Виберіть, чи Bulwark має відкривати посилання mailto: і webcal: у новій вкладці, чи повторно використовувати відкритий сеанс. Для варіанта активного сеансу потрібен дозвіл на сповіщення, щоб ви могли натиснути резервне сповіщення й вивести Bulwark на передній план, якщо браузер блокує фокус.",
"protocol_open_mode_active_session": "Якщо можливо, відкривати в активному сеансі",
"protocol_open_mode_new_tab": "Завжди відкривати нову вкладку",
"focus_notification_title": "Відкрити Bulwark",
"focus_notification_body": "Посилання було відкрито в Bulwark. Натисніть, щоб вивести вікно на передній план.",
"webcal_label": "Посилання календаря",
"webcal_description": "Відкриває посилання webcal: у Bulwark із попередньо заповненим діалогом підписки на календар.",
"register_mailto": "Зареєструвати поштову програму",
"register_webcal": "Зареєструвати програму календаря",
"mailto_registered": "Реєстрацію обробника електронної пошти запитано",
"webcal_registered": "Реєстрацію обробника календаря запитано",
"registration_failed": "Не вдалося зареєструвати обробник протоколу",
"opening_mailto": "Відкриття редактора...",
"opening_webcal": "Відкриття календаря...",
"browser_note": "Браузер або операційна система може попросити підтвердження та може вимагати, щоб Bulwark був встановлений, перш ніж його можна буде вибрати програмою за замовчуванням.",
"select_account_title": "Виберіть акаунт",
"select_mailto_account": "Виберіть акаунт, у якому слід відкрити це посилання електронної пошти.",
"select_webcal_account": "Виберіть акаунт, у якому слід відкрити це посилання календаря.",
"select_account_note": "Цей вибір застосовується лише до цього посилання протоколу.",
"detail_to": "Кому",
"detail_subject": "Тема",
"detail_no_subject": "Без теми",
"detail_calendar": "Календар",
"detail_source": "Джерело",
"active_account": "Активний",
"switching_account": "Перемикання акаунта..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "Програми бічної панелі", "modal_title": "Програми бічної панелі",
"add_new": "Додати додаток", "add_new": "Додати додаток",
@@ -733,6 +767,7 @@
"files": "Файли", "files": "Файли",
"contacts": "Контакти", "contacts": "Контакти",
"encryption": "Шифрування", "encryption": "Шифрування",
"protocol_handlers": "Програми за замовчуванням",
"sidebar_apps": "Програми бічної панелі", "sidebar_apps": "Програми бічної панелі",
"notifications": "Сповіщення", "notifications": "Сповіщення",
"layout": "Макет", "layout": "Макет",
@@ -2431,6 +2466,15 @@
"file_too_large": "Файл перевищує обмеження в 10 Мб", "file_too_large": "Файл перевищує обмеження в 10 Мб",
"invalid_format": "Недійсний формат файлу календаря" "invalid_format": "Недійсний формат файлу календаря"
}, },
"webcal_action": {
"title": "Відкрити посилання календаря",
"description": "Як ви хочете використати \"{name}\"?",
"import_title": "Імпортувати один раз",
"import_description": "Завантажити події зараз і скопіювати їх до одного з ваших календарів.",
"subscribe_title": "Підписатися",
"subscribe_description": "Автоматично синхронізувати цей календар як окремий календар.",
"cancel": "Скасувати"
},
"management": { "management": {
"title": "Управління календарем", "title": "Управління календарем",
"description": "Створюйте, перейменовуйте та налаштовуйте свої календарі. Клацніть правою кнопкою миші календар на бічній панелі, щоб швидко змінити його колір.", "description": "Створюйте, перейменовуйте та налаштовуйте свої календарі. Клацніть правою кнопкою миші календар на бічній панелі, щоб швидко змінити його колір.",
+44
View File
@@ -134,6 +134,40 @@
"add_app": "应用", "add_app": "应用",
"shared": "共享" "shared": "共享"
}, },
"protocol_handlers": {
"title": "默认应用",
"description": "选择是否在 Bulwark 中打开电子邮件和日历链接。从技术上讲,Bulwark 会注册为 mailto: 和 webcal: 链接的协议处理程序。",
"unsupported": "此浏览器或连接不支持手动注册协议处理程序。你仍可尝试通过浏览器或系统设置使用已安装的 PWA。",
"mailto_label": "电子邮件链接",
"mailto_description": "在 Bulwark 中打开 mailto: 链接,并预先填好撰写窗口。",
"protocol_open_mode_label": "打开协议链接时",
"protocol_open_mode_description": "选择 Bulwark 是在新标签页中打开 mailto: 和 webcal: 链接,还是复用已打开的会话。活动会话选项需要通知权限,这样当浏览器阻止聚焦时,你可以点击备用通知将 Bulwark 窗口带到前台。",
"protocol_open_mode_active_session": "尽可能在活动会话中打开",
"protocol_open_mode_new_tab": "始终打开新标签页",
"focus_notification_title": "打开 Bulwark",
"focus_notification_body": "链接已在 Bulwark 中打开。点击可将窗口带到前台。",
"webcal_label": "日历链接",
"webcal_description": "在 Bulwark 中打开 webcal: 链接,并预先填好日历订阅对话框。",
"register_mailto": "注册电子邮件应用",
"register_webcal": "注册日历应用",
"mailto_registered": "已请求注册电子邮件处理程序",
"webcal_registered": "已请求注册日历处理程序",
"registration_failed": "协议处理程序注册失败",
"opening_mailto": "正在打开撰写窗口...",
"opening_webcal": "正在打开日历...",
"browser_note": "你的浏览器或操作系统可能会要求确认,并且可能需要先安装 Bulwark,才能将其选为默认应用。",
"select_account_title": "选择账户",
"select_mailto_account": "选择用于打开此电子邮件链接的账户。",
"select_webcal_account": "选择用于打开此日历链接的账户。",
"select_account_note": "此选择仅适用于此协议链接。",
"detail_to": "收件人",
"detail_subject": "主题",
"detail_no_subject": "无主题",
"detail_calendar": "日历",
"detail_source": "来源",
"active_account": "活动",
"switching_account": "正在切换账户..."
},
"sidebar_apps": { "sidebar_apps": {
"modal_title": "侧边栏应用", "modal_title": "侧边栏应用",
"add_new": "添加应用", "add_new": "添加应用",
@@ -733,6 +767,7 @@
"files": "文件", "files": "文件",
"contacts": "联系人", "contacts": "联系人",
"encryption": "加密", "encryption": "加密",
"protocol_handlers": "默认应用",
"sidebar_apps": "侧边栏应用", "sidebar_apps": "侧边栏应用",
"notifications": "通知", "notifications": "通知",
"layout": "布局", "layout": "布局",
@@ -2431,6 +2466,15 @@
"file_too_large": "文件超过 10MB 限制", "file_too_large": "文件超过 10MB 限制",
"invalid_format": "日历文件格式无效" "invalid_format": "日历文件格式无效"
}, },
"webcal_action": {
"title": "打开日历链接",
"description": "您想如何使用 \"{name}\"",
"import_title": "导入一次",
"import_description": "立即获取事件并复制到您的某个日历中。",
"subscribe_title": "订阅",
"subscribe_description": "将此日历作为单独的日历自动同步。",
"cancel": "取消"
},
"management": { "management": {
"title": "日历管理", "title": "日历管理",
"description": "创建、重命名和自定义您的日历。右键单击侧栏中的日历可快速更改其颜色。", "description": "创建、重命名和自定义您的日历。右键单击侧栏中的日历可快速更改其颜色。",
+3 -3
View File
@@ -106,9 +106,9 @@ export async function proxy(request: NextRequest) {
`media-src 'self' blob:`, `media-src 'self' blob:`,
].join("; "); ].join("; ");
// Skip intl middleware for /admin and /setup routes - they have their // Skip intl middleware for routes outside the localized app tree.
// own layout outside the [locale] tree.
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/'); const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
const isProtocolRoute = pathname === '/protocol' || pathname.startsWith('/protocol/');
const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/'); const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/');
// When localePrefix is 'always', paths that already have a locale prefix // When localePrefix is 'always', paths that already have a locale prefix
@@ -120,7 +120,7 @@ export async function proxy(request: NextRequest) {
); );
let intlResponse: ReturnType<typeof intlMiddleware> | null = null; let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
if (!isAdminRoute && !isSetupRoute && !hasLocalePrefix) { if (!isAdminRoute && !isProtocolRoute && !isSetupRoute && !hasLocalePrefix) {
try { try {
intlResponse = intlMiddleware(request); intlResponse = intlMiddleware(request);
} catch (error) { } catch (error) {
+157
View File
@@ -23,6 +23,7 @@ function getBasePath() {
} }
const BASE_PATH = getBasePath(); const BASE_PATH = getBasePath();
const MAILTO_CLIENTS = new Map();
self.addEventListener("install", () => { self.addEventListener("install", () => {
self.skipWaiting(); self.skipWaiting();
@@ -43,6 +44,48 @@ self.addEventListener("notificationclick", (event) => {
event.waitUntil(handleNotificationClick(event)); event.waitUntil(handleNotificationClick(event));
}); });
self.addEventListener("message", (event) => {
const data = event.data || {};
if (data.type === "mailto-client-ready") {
if (event.source && event.source.id) {
MAILTO_CLIENTS.set(event.source.id, {
path: typeof data.path === "string" ? data.path : "",
standalone: data.standalone === true,
clientId: typeof data.clientId === "string" ? data.clientId : "",
focusNotificationTitle: typeof data.focusNotificationTitle === "string" ? data.focusNotificationTitle : "",
focusNotificationBody: typeof data.focusNotificationBody === "string" ? data.focusNotificationBody : "",
});
}
return;
}
if (data.type === "mailto-client-gone") {
if (event.source && event.source.id) {
const current = MAILTO_CLIENTS.get(event.source.id);
if (!current
|| (typeof data.clientId === "string" && current.clientId === data.clientId)
|| (typeof data.clientId !== "string" && typeof data.path === "string" && current.path === data.path)) {
MAILTO_CLIENTS.delete(event.source.id);
}
}
return;
}
if (data.type === "open-mailto-in-client") {
event.waitUntil(handleOpenMailtoInClient(event));
return;
}
if (data.type === "focus-existing-mailto-client") {
event.waitUntil(focusExistingWindowClient(event.source && event.source.id, true));
return;
}
if (data.type !== "focus-existing-client") return;
event.waitUntil(focusExistingWindowClient(event.source && event.source.id));
});
async function handlePush(event) { async function handlePush(event) {
let payload = null; let payload = null;
try { try {
@@ -123,6 +166,11 @@ async function handlePush(event) {
async function handleNotificationClick(event) { async function handleNotificationClick(event) {
const data = event.notification.data || {}; const data = event.notification.data || {};
const tag = event.notification.tag || ""; const tag = event.notification.tag || "";
if (data.kind === "protocol-mailto-focus") {
return handleMailtoFocusNotificationClick();
}
const targetUrl = buildClickUrl(data); const targetUrl = buildClickUrl(data);
const allClients = await self.clients.matchAll({ const allClients = await self.clients.matchAll({
@@ -160,6 +208,115 @@ async function handleNotificationClick(event) {
} }
} }
async function focusExistingWindowClient(sourceClientId, requireMailtoReady) {
const entry = await findReusableWindowClientEntry(sourceClientId, requireMailtoReady);
const client = entry && entry.client;
if (client && "focus" in client) {
return client.focus();
}
}
async function handleMailtoFocusNotificationClick() {
const entry = await findReusableWindowClientEntry(null, true);
const client = entry && entry.client;
if (client && "focus" in client) {
try {
return await client.focus();
} catch (_) {
// Fall through to opening a new app window if activation is still blocked.
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(`${BASE_PATH}/`);
}
}
async function handleOpenMailtoInClient(event) {
const data = event.data || {};
const responsePort = event.ports && event.ports[0];
const entry = await findReusableWindowClientEntry(event.source && event.source.id, true);
const client = entry && entry.client;
const state = entry && entry.state;
if (!client || !state || !state.clientId) {
responsePort && responsePort.postMessage({ delivered: false });
return;
}
try {
client.postMessage({ type: "mailto-request", id: data.id, clientId: state.clientId, value: data.value });
} catch (_) {
responsePort && responsePort.postMessage({ delivered: false });
return;
}
if ("focus" in client) {
try {
await client.focus();
} catch (_) {
// Delivery succeeded; focusing can still be blocked by browser policy.
await showMailtoFocusNotification(state);
}
}
responsePort && responsePort.postMessage({ delivered: true });
}
async function showMailtoFocusNotification(state) {
try {
await self.registration.showNotification(state.focusNotificationTitle || "Bulwark", {
body: state.focusNotificationBody || "The request was opened in Bulwark. Click to bring it to the front.",
tag: "bulwark-mailto-focus",
icon: `${BASE_PATH}/icon-192x192.png`,
badge: `${BASE_PATH}/icon-192x192.png`,
data: { kind: "protocol-mailto-focus" },
renotify: true,
});
} catch (_) {
// Notification permission may be missing; the mailto request was still delivered.
}
}
async function findReusableWindowClientEntry(sourceClientId, requireMailtoReady) {
const scopedPath = BASE_PATH ? `${BASE_PATH}/` : "/";
const allClients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
const candidates = [];
for (const client of allClients) {
if (client.id === sourceClientId) continue;
const state = MAILTO_CLIENTS.get(client.id);
if (requireMailtoReady && !state) continue;
try {
const url = new URL(client.url);
if (url.origin !== self.location.origin) continue;
if (!url.pathname.startsWith(scopedPath)) continue;
if (url.pathname.includes("/protocol/")) continue;
candidates.push({ client, state, score: getReusableClientScore(state) });
} catch (_) {
// Detached clients can disappear while iterating.
}
}
candidates.sort((a, b) => a.score - b.score);
return candidates[0];
}
function getReusableClientScore(state) {
if (!state) return 4;
const isMailSection = state.path === "/" || state.path === "";
if (state.standalone && isMailSection) return 0;
if (isMailSection) return 1;
if (state.standalone) return 2;
return 3;
}
function buildClickUrl(data) { function buildClickUrl(data) {
if (!data) return `${BASE_PATH}/`; if (!data) return `${BASE_PATH}/`;
if (data.kind === "email" && data.emailId) { if (data.kind === "email" && data.emailId) {
+17 -1
View File
@@ -41,6 +41,7 @@ export type ToolbarPosition = 'top' | 'below-subject';
export type ArchiveMode = 'single' | 'year' | 'month'; export type ArchiveMode = 'single' | 'year' | 'month';
export type MailLayout = 'split' | 'focus' | 'horizontal'; export type MailLayout = 'split' | 'focus' | 'horizontal';
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s'; export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
export type ProtocolOpenMode = 'active-session' | 'new-tab';
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam'; export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
export type HoverActionsMode = 'inline' | 'floating'; export type HoverActionsMode = 'inline' | 'floating';
@@ -176,6 +177,9 @@ interface SettingsState {
emailNotificationSound: boolean; emailNotificationSound: boolean;
notificationSoundChoice: NotificationSoundChoice; notificationSoundChoice: NotificationSoundChoice;
// Protocol Handlers
protocolOpenMode: ProtocolOpenMode;
// Calendar Notifications // Calendar Notifications
calendarNotificationsEnabled: boolean; calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean; calendarNotificationSound: boolean;
@@ -330,6 +334,9 @@ const DEFAULT_SETTINGS = {
emailNotificationSound: true, emailNotificationSound: true,
notificationSoundChoice: 'default' as NotificationSoundChoice, notificationSoundChoice: 'default' as NotificationSoundChoice,
// Protocol Handlers
protocolOpenMode: 'new-tab' as ProtocolOpenMode,
// Calendar Notifications // Calendar Notifications
calendarNotificationsEnabled: true, calendarNotificationsEnabled: true,
calendarNotificationSound: true, calendarNotificationSound: true,
@@ -477,6 +484,7 @@ export const useSettingsStore = create<SettingsState>()(
emailNotificationsEnabled: state.emailNotificationsEnabled, emailNotificationsEnabled: state.emailNotificationsEnabled,
emailNotificationSound: state.emailNotificationSound, emailNotificationSound: state.emailNotificationSound,
notificationSoundChoice: state.notificationSoundChoice, notificationSoundChoice: state.notificationSoundChoice,
protocolOpenMode: state.protocolOpenMode,
calendarNotificationsEnabled: state.calendarNotificationsEnabled, calendarNotificationsEnabled: state.calendarNotificationsEnabled,
calendarNotificationSound: state.calendarNotificationSound, calendarNotificationSound: state.calendarNotificationSound,
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled, calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
@@ -523,6 +531,10 @@ export const useSettingsStore = create<SettingsState>()(
return false; return false;
} }
if (typeof settings.protocolOpenMode !== 'string' && typeof settings.protocolMailtoOpenMode === 'string') {
settings.protocolOpenMode = settings.protocolMailtoOpenMode;
}
// Apply settings // Apply settings
Object.keys(settings).forEach((key) => { Object.keys(settings).forEach((key) => {
if (key in DEFAULT_SETTINGS) { if (key in DEFAULT_SETTINGS) {
@@ -697,13 +709,17 @@ export const useSettingsStore = create<SettingsState>()(
}), }),
{ {
name: 'settings-storage', name: 'settings-storage',
version: 2, version: 3,
migrate: (persisted, version) => { migrate: (persisted, version) => {
const state = persisted as Record<string, unknown>; const state = persisted as Record<string, unknown>;
if (version < 2 && state.listDensity) { if (version < 2 && state.listDensity) {
state.density = state.listDensity; state.density = state.listDensity;
delete state.listDensity; delete state.listDensity;
} }
if (version < 3 && typeof state.protocolOpenMode !== 'string' && typeof state.protocolMailtoOpenMode === 'string') {
state.protocolOpenMode = state.protocolMailtoOpenMode;
}
delete state.protocolMailtoOpenMode;
return state as unknown as SettingsState; return state as unknown as SettingsState;
}, },
onRehydrateStorage: () => { onRehydrateStorage: () => {