From 30f51e5ca4e81060be134bc261deaa42d58a2a20 Mon Sep 17 00:00:00 2001 From: ghozty <12158077+ghozty@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:14:54 +0300 Subject: [PATCH] feat: name the app with the configured APP_NAME in user-facing strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some user-facing strings still said "Bulwark" on a deployment with its own APP_NAME: Settings > General > Default apps (protocol handlers), the "Open Bulwark" notification, the rate-limit banner and toast, the plugin connect dialog, one plugin permission text and the connector page. These strings now take an {appName} placeholder, filled by productName(): the configured APP_NAME, or "Bulwark" when it is unset or the default "Webmail" (the same rule as the TOTP issuer and the About card, #1160). A stock install reads as before. The settings search (the settings page and api.app.searchSettings) indexes these texts with the name filled in, so "bulwark" or the configured name finds Default apps and no result carries the raw placeholder. Locales: the 13 keys in every locale that has "Bulwark" in them. Languages that inflect the name (cs, et, hu, ko, ro, sk, tr) use a neutral form such as "{appName} uygulamasında" instead of a declined "Bulwark'ta"; a native check of those is welcome. Not changed on purpose: the mobile app, push relay, Lite, demo and admin texts, which speak about Bulwark itself. Closes #1314 Co-Authored-By: Claude Opus 5.5 --- components/connector/connector-client.tsx | 15 +++-- components/mail/mail-app.tsx | 5 +- .../__tests__/plugin-connect-dialog.test.tsx | 16 +++++ components/plugins/plugin-connect-dialog.tsx | 9 ++- components/plugins/plugin-permission-list.tsx | 5 +- .../protocol-launch-handler-provider.tsx | 9 ++- .../providers/rate-limit-toast-provider.tsx | 7 ++- .../settings/protocol-handler-settings.tsx | 13 ++-- components/settings/settings-app.tsx | 9 ++- .../plugin-host-api-app-info.test.ts | 16 +++++ lib/__tests__/product-name.test.ts | 59 +++++++++++++++++++ lib/__tests__/settings-search.test.ts | 41 +++++++++++++ lib/plugin-sandbox/host-api-app.ts | 19 +++--- lib/product-name.ts | 7 +++ lib/settings-search.ts | 12 ++++ locales/ar/common.json | 26 ++++---- locales/ca/common.json | 26 ++++---- locales/cs/common.json | 22 +++---- locales/da/common.json | 26 ++++---- locales/de/common.json | 26 ++++---- locales/en/common.json | 26 ++++---- locales/es/common.json | 26 ++++---- locales/et/common.json | 26 ++++---- locales/fa/common.json | 22 +++---- locales/fr/common.json | 26 ++++---- locales/he/common.json | 26 ++++---- locales/hu/common.json | 26 ++++---- locales/it/common.json | 26 ++++---- locales/ja/common.json | 26 ++++---- locales/ko/common.json | 24 ++++---- locales/lv/common.json | 26 ++++---- locales/mn/common.json | 24 ++++---- locales/nb/common.json | 26 ++++---- locales/nl/common.json | 26 ++++---- locales/pl/common.json | 26 ++++---- locales/pt/common.json | 26 ++++---- locales/ro/common.json | 26 ++++---- locales/ru/common.json | 26 ++++---- locales/sk/common.json | 24 ++++---- locales/tr/common.json | 26 ++++---- locales/uk/common.json | 26 ++++---- locales/zh-TW/common.json | 26 ++++---- locales/zh/common.json | 26 ++++---- 43 files changed, 567 insertions(+), 389 deletions(-) create mode 100644 lib/__tests__/product-name.test.ts create mode 100644 lib/product-name.ts diff --git a/components/connector/connector-client.tsx b/components/connector/connector-client.tsx index 464815918..f8c75f50c 100644 --- a/components/connector/connector-client.tsx +++ b/components/connector/connector-client.tsx @@ -1,9 +1,11 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; +import { useConfig } from '@/hooks/use-config'; import { resolveTarget, type ResolveResult } from '@/lib/connector/registry'; import { appPath } from '@/lib/deep-links'; import { replaceWindowLocation } from '@/lib/browser-navigation'; +import { productName } from '@/lib/product-name'; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || ''; @@ -25,6 +27,7 @@ const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || ''; */ export function ConnectorClient({ target }: { target: string }) { const [result, setResult] = useState(null); + const name = productName(useConfig().appName); useEffect(() => { setResult(resolveTarget(target, new URLSearchParams(window.location.search))); @@ -52,12 +55,12 @@ export function ConnectorClient({ target }: { target: string }) { if (result.kind === 'unknown-target') { return ( -

This link is newer than this Bulwark

+

This link is newer than this {name}

It opens something {APP_VERSION ? `version ${APP_VERSION} ` : 'this version '} - does not know about. Updating Bulwark should teach it. + does not know about. Updating {name} should teach it.

- +
); } @@ -71,19 +74,19 @@ export function ConnectorClient({ target }: { target: string }) { {result.param} is missing or malformed. Nothing was opened.

- + ); } -function Actions() { +function Actions({ name }: { name: string }) { return (

- Open Bulwark + Open {name}

); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 3de801e95..f062ec582 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -6,6 +6,8 @@ import { Sidebar } from "@/components/layout/sidebar"; import { EmailList } from "@/components/email/email-list"; import { MvComposeInline, MvComposeWindow } from "@/components/email/mv-compose-window"; import { usePinnedPerSession } from "@/hooks/use-pinned-per-session"; +import { useConfig } from "@/hooks/use-config"; +import { productName } from "@/lib/product-name"; import { MessageListTabs } from "@/components/email/message-list-tabs"; import dynamic from "next/dynamic"; import type { ComposerDraftData } from "@/components/email/email-composer"; @@ -200,6 +202,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const linkSegments = useLiteLinkSegments('mail', routeSegments); const t = useTranslations(); const tCommon = useTranslations('common'); + const appName = productName(useConfig().appName); const tQuote = useTranslations('quote_header'); const mailLayout = useEffectiveMailLayout(); const interfaceLayout = useSettingsStore((state) => state.interfaceLayout); @@ -4494,7 +4497,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{tCommon('rate_limited_title')} - {tCommon('rate_limited_detail', { seconds: rateLimitSecondsLeft })} + {tCommon('rate_limited_detail', { seconds: rateLimitSecondsLeft, appName })}
)} {connectionLost && ( diff --git a/components/plugins/__tests__/plugin-connect-dialog.test.tsx b/components/plugins/__tests__/plugin-connect-dialog.test.tsx index 86fa4894e..ec676144f 100644 --- a/components/plugins/__tests__/plugin-connect-dialog.test.tsx +++ b/components/plugins/__tests__/plugin-connect-dialog.test.tsx @@ -10,6 +10,8 @@ vi.mock('@/lib/browser-navigation', async (importOriginal) => ({ ...(await importOriginal()), apiFetch: (...args: unknown[]) => apiFetch(...args), })); +const appName = vi.hoisted(() => ({ value: 'Webmail' })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: appName.value }) })); import { PluginConnectDialog } from '../plugin-connect-dialog'; import { originFromInput, requestConnect, type ConnectResult } from '@/lib/plugin-sandbox/connect-dialog'; @@ -17,6 +19,7 @@ import { originFromInput, requestConnect, type ConnectResult } from '@/lib/plugi beforeEach(() => { apiFetch.mockReset(); apiFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + appName.value = 'Webmail'; }); function open(mode: 'password' | 'signin' = 'password') { @@ -62,6 +65,19 @@ describe('connect dialog', () => { expect(JSON.parse(apiFetch.mock.calls[0][1].body)).not.toHaveProperty('secret'); }); + it('names the app as configured, and Bulwark on a stock install', async () => { + const first = render(); + const result = open('signin'); + expect(screen.getByText(/intro_signin /)).toHaveTextContent('"appName":"Bulwark"'); + first.unmount(); + + appName.value = 'Acme Mail'; + render(); + expect(screen.getByText(/intro_signin /)).toHaveTextContent('"appName":"Acme Mail"'); + act(() => { fireEvent.keyDown(window, { key: 'Escape' }); }); + await expect(result).resolves.toEqual({ connected: false }); + }); + it('shows the server error and stays open', async () => { apiFetch.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'The server address must start with https://' }), { status: 400 })); render(); diff --git a/components/plugins/plugin-connect-dialog.tsx b/components/plugins/plugin-connect-dialog.tsx index 962e70c54..611d0c221 100644 --- a/components/plugins/plugin-connect-dialog.tsx +++ b/components/plugins/plugin-connect-dialog.tsx @@ -4,6 +4,8 @@ import { useEffect, useId, useState, useSyncExternalStore } from 'react'; import { useTranslations } from 'next-intl'; import { KeyRound } from '@/components/icons'; import { Button } from '@/components/ui/button'; +import { useConfig } from '@/hooks/use-config'; +import { productName } from '@/lib/product-name'; import { currentConnect, originFromInput, @@ -19,6 +21,7 @@ import { export function PluginConnectDialog() { const request = useSyncExternalStore(subscribeConnect, currentConnect, () => null); const t = useTranslations('plugins.connect'); + const appName = productName(useConfig().appName); const titleId = useId(); const [server, setServer] = useState(''); const [username, setUsername] = useState(''); @@ -78,10 +81,10 @@ export function PluginConnectDialog() { const host = origin ? new URL(origin).host : null; const intro = mode === 'signin' - ? t('intro_signin', { name: pluginName, label: spec.label }) + ? t('intro_signin', { name: pluginName, label: spec.label, appName }) : host - ? t('intro_password', { name: pluginName, label: spec.label, host }) - : t('intro_password_any', { name: pluginName, label: spec.label }); + ? t('intro_password', { name: pluginName, label: spec.label, host, appName }) + : t('intro_password_any', { name: pluginName, label: spec.label, appName }); return (
{t('none')}

; @@ -39,7 +42,7 @@ export function PluginPermissionList({ permissions, httpOrigins, apiPostPaths, c {risky && }
{t(`${key}.title`)}
- {!compact &&
{t(`${key}.body`)}
} + {!compact &&
{t(`${key}.body`, { appName })}
} {targets && targets.length > 0 && (
    {targets.map((target) => ( diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index 7e8651e2d..885203711 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -4,7 +4,9 @@ import { useEffect } from "react"; import type { ReactNode } from "react"; import { useTranslations } from "next-intl"; import { usePathname, useRouter } from "@/i18n/navigation"; +import { useConfig } from "@/hooks/use-config"; import { getPathPrefix } from "@/lib/browser-navigation"; +import { productName } from "@/lib/product-name"; import { parseMailto } from "@/lib/protocol-handlers/mailto"; import { parseWebcal } from "@/lib/protocol-handlers/webcal"; import { @@ -68,6 +70,7 @@ interface ProtocolLaunchHandlerProviderProps { export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandlerProviderProps) { const t = useTranslations("protocol_handlers"); + const appName = productName(useConfig().appName); const router = useRouter(); const pathname = usePathname(); @@ -81,10 +84,10 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle }, () => ({ path: pathname, standalone: isStandaloneDisplayMode(), - focusNotificationTitle: t("focus_notification_title"), - focusNotificationBody: t("focus_notification_body"), + focusNotificationTitle: t("focus_notification_title", { appName }), + focusNotificationBody: t("focus_notification_body", { appName }), })); - }, [pathname, router, t]); + }, [pathname, router, t, appName]); useEffect(() => { if (typeof window === "undefined" || !window.launchQueue) return; diff --git a/components/providers/rate-limit-toast-provider.tsx b/components/providers/rate-limit-toast-provider.tsx index c3c0287c7..140fa2af9 100644 --- a/components/providers/rate-limit-toast-provider.tsx +++ b/components/providers/rate-limit-toast-provider.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react'; import { useTranslations } from 'next-intl'; +import { useConfig } from '@/hooks/use-config'; +import { productName } from '@/lib/product-name'; import { toast } from '@/stores/toast-store'; type RateLimitBlockedDetail = { @@ -10,6 +12,7 @@ type RateLimitBlockedDetail = { export function RateLimitToastProvider({ children }: { children: React.ReactNode }) { const tCommon = useTranslations('common'); + const appName = productName(useConfig().appName); useEffect(() => { const onRateLimitBlocked = (event: Event) => { @@ -18,13 +21,13 @@ export function RateLimitToastProvider({ children }: { children: React.ReactNode toast.warning( tCommon('rate_limited_action_title'), - tCommon('rate_limited_action_detail', { seconds }), + tCommon('rate_limited_action_detail', { seconds, appName }), ); }; window.addEventListener('bulwark:rate-limit-blocked', onRateLimitBlocked); return () => window.removeEventListener('bulwark:rate-limit-blocked', onRateLimitBlocked); - }, [tCommon]); + }, [tCommon, appName]); return <>{children}; } \ No newline at end of file diff --git a/components/settings/protocol-handler-settings.tsx b/components/settings/protocol-handler-settings.tsx index 13c554085..48f1e9d05 100644 --- a/components/settings/protocol-handler-settings.tsx +++ b/components/settings/protocol-handler-settings.tsx @@ -3,7 +3,9 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; +import { useConfig } from "@/hooks/use-config"; import { getPathPrefix } from "@/lib/browser-navigation"; +import { productName } from "@/lib/product-name"; import { useSettingsStore } from "@/stores/settings-store"; import type { ProtocolOpenMode } from "@/stores/settings-store"; import { toast } from "@/stores/toast-store"; @@ -35,6 +37,7 @@ interface ProtocolHandlerSettingsProps { export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSettingsProps) { const t = useTranslations("protocol_handlers"); + const appName = productName(useConfig().appName); const protocolOpenMode = useSettingsStore((state) => state.protocolOpenMode); const updateSetting = useSettingsStore((state) => state.updateSetting); const [supported, setSupported] = useState(false); @@ -74,24 +77,24 @@ export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSet }; return ( - + {!supported && (
    {t("unsupported")}
    )} - + {renderRegistrationControl("mailto")} {supportsCalendar && ( - + {renderRegistrationControl("webcal")} )} - +