diff --git a/app/(main)/[locale]/popout/compose/page.tsx b/app/(main)/[locale]/popout/compose/page.tsx
new file mode 100644
index 000000000..88ab7d524
--- /dev/null
+++ b/app/(main)/[locale]/popout/compose/page.tsx
@@ -0,0 +1,7 @@
+import { PopoutComposeApp } from "@/components/popout/popout-compose-app";
+
+// The composer in a window of its own (lib/popout). Not in Lite: scripts/lite
+// removes this tree, and "Open in new window" isn't offered there.
+export default function PopoutComposePage() {
+ return ;
+}
diff --git a/app/(main)/[locale]/popout/message/[id]/page.tsx b/app/(main)/[locale]/popout/message/[id]/page.tsx
new file mode 100644
index 000000000..806370780
--- /dev/null
+++ b/app/(main)/[locale]/popout/message/[id]/page.tsx
@@ -0,0 +1,8 @@
+import { PopoutMessageApp } from "@/components/popout/popout-message-app";
+
+// One message in a window of its own (lib/popout). Not in Lite: scripts/lite
+// removes this tree, since a static export can't list every message id.
+export default async function PopoutMessagePage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ return ;
+}
diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx
index a1015bd64..127deb308 100644
--- a/app/(main)/[locale]/pro/page.tsx
+++ b/app/(main)/[locale]/pro/page.tsx
@@ -509,7 +509,7 @@ export default function ProHome() {
return (
{/* data-pro-shell: a page skeleton inside a pane draws its own frame, not the Pro one. */}
-
+
{/* Once for the whole shell: email and folder tabs have no app strip, and split panes would show it twice. */}
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx
index d186b72da..e2704bcc8 100644
--- a/app/(main)/layout.tsx
+++ b/app/(main)/layout.tsx
@@ -20,8 +20,11 @@ import { locales, defaultLocale } from "@/i18n/routing";
import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite";
import { accessibilityBootScript } from "@/lib/accessibility-settings";
import { bootSkeletonScript } from "@/lib/boot-skeleton/boot-script";
+import { WindowTitleBar } from "@/components/layout/window-title-bar";
import "../globals.css";
import "../print.css";
+import "../desktop.css";
+import { desktopBootScript } from "@/lib/desktop/boot";
// This layout renders and sits ABOVE the [locale] segment, so
// next-intl's getLocale() returns the default locale here - emitting
@@ -308,6 +311,7 @@ export default async function RootLayout({
dangerouslySetInnerHTML={{
__html: `
(function() {${IS_LITE ? liteLocaleBootstrap() : ""}
+ ${desktopBootScript()}
try {
const stored = localStorage.getItem('theme-storage');
const theme = stored ? JSON.parse(stored).state.theme : 'system';
@@ -330,6 +334,7 @@ export default async function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} ${hankenGrotesk.variable} antialiased`}
>
+
{!themeColorConfigured && }
diff --git a/app/api/auth/desktop-oauth/route.ts b/app/api/auth/desktop-oauth/route.ts
new file mode 100644
index 000000000..c6fa99102
--- /dev/null
+++ b/app/api/auth/desktop-oauth/route.ts
@@ -0,0 +1,86 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { logger } from '@/lib/logger';
+import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
+import { discoverOAuth } from '@/lib/oauth/discovery';
+import { DEFAULT_OAUTH_SCOPES, withProviderDataScopes } from '@/lib/oauth/scopes';
+import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
+import { resolveCustomUpstream } from '@/lib/oauth/custom-upstream';
+import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
+
+/** The client id on a server that won't register one: 0.16 accepts any id. */
+const FALLBACK_CLIENT_ID = 'bulwark-desktop';
+const REGISTERED_REDIRECT = 'http://127.0.0.1/bulwark-desktop/callback';
+const REGISTER_TIMEOUT_MS = 5000;
+
+function isLoopbackRedirect(value: unknown): boolean {
+ if (typeof value !== 'string') return false;
+ try {
+ const url = new URL(value);
+ return url.protocol === 'http:' && url.hostname === '127.0.0.1' && url.pathname === '/bulwark-desktop/callback';
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Registers the desktop app as a public client. Stalwart 1.0 needs this: its
+ * ids are stateless, so the same metadata gives the same id and nothing is
+ * stored. It accepts a loopback redirect and ignores the port when matching.
+ */
+async function registerClient(base: string, scope: string): Promise {
+ try {
+ const response = await fetch(`${base}/auth/register`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ client_name: 'Bulwark',
+ redirect_uris: [REGISTERED_REDIRECT],
+ token_endpoint_auth_method: 'none',
+ grant_types: ['authorization_code', 'refresh_token'],
+ response_types: ['code'],
+ scope,
+ }),
+ redirect: 'error',
+ signal: AbortSignal.timeout(REGISTER_TIMEOUT_MS),
+ });
+ if (!response.ok) return FALLBACK_CLIENT_ID;
+ const body = (await response.json()) as { client_id?: unknown };
+ return typeof body.client_id === 'string' && body.client_id ? body.client_id : FALLBACK_CLIENT_ID;
+ } catch (error) {
+ logger.warn('Desktop OAuth: client registration failed', { error: error instanceof Error ? error.message : String(error) });
+ return FALLBACK_CLIENT_ID;
+ }
+}
+
+/**
+ * What the desktop app needs to send the user to a server's own sign-in page:
+ * the authorize endpoint and a client id. Only in the desktop build. Discovery
+ * runs here rather than in the page, for the same reason as on the login page:
+ * the documents often have no CORS headers.
+ */
+export async function POST(request: NextRequest) {
+ if (!IS_DESKTOP_BUILD) return NextResponse.json({ error: 'Not found' }, { status: 404 });
+ const crossOrigin = rejectCrossOriginRequest(request);
+ if (crossOrigin) return crossOrigin;
+ try {
+ const { serverUrl, redirectUri } = await request.json();
+ if (!isLoopbackRedirect(redirectUri)) {
+ return NextResponse.json({ error: 'invalid_redirect' }, { status: 400 });
+ }
+ const upstream = await resolveCustomUpstream(serverUrl, FALLBACK_CLIENT_ID);
+ if (!upstream) return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
+
+ const metadata = await discoverOAuth(upstream.serverUrl, { validateEndpoint: getDiscoveryValidator(upstream.serverUrl) });
+ if (!metadata) return NextResponse.json({ error: 'no_oauth' }, { status: 404 });
+
+ const scope = withProviderDataScopes(DEFAULT_OAUTH_SCOPES, metadata.scopes_supported);
+ return NextResponse.json({
+ authorization_endpoint: metadata.authorization_endpoint,
+ client_id: await registerClient(upstream.serverUrl, scope),
+ scope,
+ });
+ } catch (error) {
+ logger.error('Desktop OAuth discovery failed', { error: error instanceof Error ? error.message : 'Unknown error' });
+ return NextResponse.json({ error: 'discovery_failed' }, { status: 503 });
+ }
+}
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index 1afa1ab96..32b044ac2 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -184,7 +184,7 @@ export async function POST(request: NextRequest) {
// password. `trusted` only relaxes the public-address requirement.
// Wrong credentials tried from here count against this server's address
// upstream, so each client gets a failure budget (lib/auth/verify-budget.ts).
- const clientIp = loginClientAddress(request);
+ const clientIp = loginClientAddress(request, upstreamUrl);
const refund = await reserveLoginAttempt(clientIp);
if (!refund) {
return NextResponse.json(
diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts
index c2afc77b6..516f5ac11 100644
--- a/app/api/auth/stalwart-context/route.ts
+++ b/app/api/auth/stalwart-context/route.ts
@@ -80,7 +80,7 @@ export async function POST(request: NextRequest) {
// private network) - never the credential check.
// Wrong credentials tried from here count against this server's address
// upstream, so each client gets a failure budget (lib/auth/verify-budget.ts).
- const clientIp = loginClientAddress(request);
+ const clientIp = loginClientAddress(request, upstreamUrl);
const refund = await reserveLoginAttempt(clientIp);
if (!refund) {
return NextResponse.json(
diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts
index 59b5834c7..ba2d3d9ee 100644
--- a/app/api/auth/token/route.ts
+++ b/app/api/auth/token/route.ts
@@ -41,6 +41,14 @@ import {
writeSignInStamp,
} from '@/lib/auth/session-age';
import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
+import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
+import {
+ clearCustomUpstream,
+ readCustomUpstream,
+ resolveCustomUpstream,
+ writeCustomUpstream,
+ type CustomUpstream,
+} from '@/lib/oauth/custom-upstream';
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
@@ -57,13 +65,13 @@ function getSlot(request: NextRequest): number {
// not hold it up for long.
const REVOCATION_TIMEOUT_MS = 3000;
-async function revokeRefreshToken(token: string, serverId: string | null, metadata: OAuthMetadata | null): Promise {
+async function revokeRefreshToken(token: string, serverId: string | null, metadata: OAuthMetadata | null, upstream: CustomUpstream | null): Promise {
const revocationEndpoint = metadata?.revocation_endpoint;
if (!revocationEndpoint) return;
const params = buildOAuthParams({
token,
token_type_hint: 'refresh_token',
- }, serverId, await defaultClientOptions(serverId).catch(() => ({ fallbackClientId: DEFAULT_CLIENT_ID })));
+ }, serverId, await defaultClientOptions(serverId, upstream).catch(() => ({ fallbackClientId: DEFAULT_CLIENT_ID })));
try {
// Never follow a redirect with the refresh token and client secret in
@@ -90,12 +98,12 @@ async function revokeRefreshToken(token: string, serverId: string | null, metada
* Stalwart, and for a logout page shared with other apps, which must keep
* working once the provider starts advertising its own.
*/
-function endSessionUrlFor(metadata: OAuthMetadata | null, serverId: string | null, idToken: string | undefined): string | null {
+function endSessionUrlFor(metadata: OAuthMetadata | null, serverId: string | null, idToken: string | undefined, upstream: CustomUpstream | null): string | null {
if (!isEndSessionEnabled()) return null;
let clientId: string;
let configuredEndpoint: string | undefined;
try {
- ({ clientId, endSessionUrl: configuredEndpoint } = getRequiredConfig(serverId));
+ ({ clientId, endSessionUrl: configuredEndpoint } = upstream ? getRequiredConfig(serverId, { upstream }) : getRequiredConfig(serverId));
} catch {
return null;
}
@@ -146,7 +154,7 @@ export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
- const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId, remember: bodyRemember } = await request.json();
+ const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId, remember: bodyRemember, custom_server_url: customServerUrl, client_id: bodyClientId } = await request.json();
// "Remember me" left unticked: the cookies end with the browser session.
// Clients from before the choice remembered every sign-in.
const remember = bodyRemember !== false;
@@ -158,7 +166,14 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
- const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
+ // The desktop app signs in to a server the user typed, with a client it
+ // registered there. Nothing configured here knows about either.
+ const customUpstream = IS_DESKTOP_BUILD && !serverId ? await resolveCustomUpstream(customServerUrl, bodyClientId) : null;
+ if (IS_DESKTOP_BUILD && !serverId && !customUpstream) {
+ return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
+ }
+
+ const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId, customUpstream ? { upstream: customUpstream } : undefined);
// A sign-in: the maximum session length counts from here, on the server
// and, from this answer, in the browser.
const signedInAt = Date.now();
@@ -187,6 +202,7 @@ export async function POST(request: NextRequest) {
} else if (cookieStore.get(serverCookieName)) {
cookieStore.delete(serverCookieName);
}
+ if (customUpstream) writeCustomUpstream(cookieStore, slot, customUpstream, remember);
return response;
} catch (error) {
@@ -260,13 +276,14 @@ export async function PUT(request: NextRequest) {
// Discovery or the token endpoint out of reach is an outage: the client
// waits it out. A 500 here would count toward signing the user out.
let tokenResponse: Response;
+ const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null;
try {
- const tokenEndpoint = await getTokenEndpoint(serverId, { fallbackClientId: DEFAULT_CLIENT_ID });
+ const tokenEndpoint = await getTokenEndpoint(serverId, upstream ? { upstream } : { fallbackClientId: DEFAULT_CLIENT_ID });
const params = buildOAuthParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
- }, serverId, await defaultClientOptions(serverId));
+ }, serverId, await defaultClientOptions(serverId, upstream));
tokenResponse = await observeUpstream('oauth', () => fetch(tokenEndpoint, {
method: 'POST',
@@ -291,8 +308,12 @@ export async function PUT(request: NextRequest) {
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
cookieStore.delete(accessTokenCookieName(slot));
+ clearCustomUpstream(cookieStore, slot);
clearIdToken(cookieStore, slot, request.nextUrl.basePath);
- return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
+ // Only invalid_grant says the login itself is over. invalid_client, or
+ // a provider refusing an early refresh (#552), says nothing about it,
+ // and the client keeps the account's local data for those.
+ return NextResponse.json({ error: oauthErrorCode(errorText) === 'invalid_grant' ? 'invalid_grant' : 'Refresh failed' }, { status: 401 });
}
return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
}
@@ -332,10 +353,23 @@ export async function PUT(request: NextRequest) {
});
} catch (error) {
logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' });
+ // On the desktop this route runs on the user's machine, so an unreachable
+ // mail server lands here. The client signs out after repeated 500s; a 503
+ // tells it to keep the session and try again.
+ if (IS_DESKTOP_BUILD) return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+function oauthErrorCode(body: string): string | null {
+ try {
+ const code = (JSON.parse(body) as { error?: unknown }).error;
+ return typeof code === 'string' ? code : null;
+ } catch {
+ return null;
+ }
+}
+
/**
* Revoke a slot's refresh token and clear its token cookies. With
* `endSession`, also returns the URL that ends the provider's session, or null
@@ -351,19 +385,20 @@ async function signOutSlot(
const refreshToken = cookieStore.get(cookieName)?.value;
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
const idToken = cookieStore.get(idTokenCookieName(slot))?.value;
+ const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null;
// With revocation off (#1388) the token is only dropped from the browser.
const tokenToRevoke = refreshToken && isRevokeOnSignOutEnabled() ? refreshToken : undefined;
let endSessionUrl: string | null = null;
if (tokenToRevoke || endSession) {
- const metadata = await getMetadata(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => {
+ const metadata = await getMetadata(serverId, upstream ? { upstream } : { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => {
logger.warn('Failed to discover OAuth metadata during logout', {
error: err instanceof Error ? err.message : 'Unknown error',
});
return null;
});
- if (tokenToRevoke) await revokeRefreshToken(tokenToRevoke, serverId, metadata);
- if (endSession) endSessionUrl = endSessionUrlFor(metadata, serverId, idToken);
+ if (tokenToRevoke) await revokeRefreshToken(tokenToRevoke, serverId, metadata, upstream);
+ if (endSession) endSessionUrl = endSessionUrlFor(metadata, serverId, idToken, upstream);
}
// Cleared whether or not revocation worked: the browser must not be able
@@ -374,6 +409,7 @@ async function signOutSlot(
if (cookieStore.get(name)) cookieStore.delete(name);
}
clearSignInStamp(cookieStore, slot);
+ if (IS_DESKTOP_BUILD) clearCustomUpstream(cookieStore, slot);
if (idToken !== undefined) clearIdToken(cookieStore, slot, basePath);
return endSessionUrl;
}
diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts
index 4c374a4f3..a16be7089 100644
--- a/app/api/auth/totp-token-exchange/route.ts
+++ b/app/api/auth/totp-token-exchange/route.ts
@@ -16,6 +16,10 @@ import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
import { writeSignInStamp } from '@/lib/auth/session-age';
+import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
+import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers';
+import { stalwartDefaultClientId } from '@/lib/auth/stalwart-password-login';
+import { writeCustomUpstream } from '@/lib/oauth/custom-upstream';
/**
* Exchange a password + (optional) TOTP code for OAuth tokens.
@@ -113,7 +117,12 @@ async function attemptLogin(
logger.info('TOTP login succeeded');
void recordLogin(username, base);
- return await storeAndRespond(result.tokens, slot, serverId, rememberMe);
+ // The desktop app's servers are in no configured list, so the refresh has to
+ // be told where this token belongs and which client it was issued to.
+ const upstream = IS_DESKTOP_BUILD && !serverId
+ ? { serverUrl: base, clientId: await stalwartDefaultClientId(base, clientId, upstreamTrusted) }
+ : null;
+ return await storeAndRespond(result.tokens, slot, serverId, rememberMe, upstream);
}
function trimUrl(url: string): string {
@@ -164,6 +173,9 @@ export async function POST(request: NextRequest) {
resolvedServerId = matchedEntry.id;
} else if (configuredServerUrl && !customRequested) {
upstreamUrl = configuredServerUrl;
+ // Spelled out as in lib/admin/jmap-servers.ts, so other builds don't hold it.
+ } else if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && isDesktopTrustedServer(serverUrl)) {
+ upstreamUrl = serverUrl;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
logger.warn('TOTP login: rejected non-public server URL');
@@ -193,7 +205,7 @@ export async function POST(request: NextRequest) {
slot,
resolvedServerId,
upstreamTrusted,
- loginClientAddress(request),
+ loginClientAddress(request, upstreamUrl),
// Clients from before the flag remembered every sign-in.
rememberMe !== false,
);
@@ -210,6 +222,7 @@ async function storeAndRespond(
slot: number,
serverId: string | null,
rememberMe: boolean,
+ upstream: { serverUrl: string; clientId: string } | null,
): Promise {
const cookieStore = await cookies();
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
@@ -227,6 +240,7 @@ async function storeAndRespond(
} else {
cookieStore.delete(serverCookieName);
}
+ if (upstream && tokens.refresh_token) writeCustomUpstream(cookieStore, slot, upstream, rememberMe);
return NextResponse.json({
access_token: tokens.access_token,
diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts
index 028d6432a..dd404e9a3 100644
--- a/app/api/auth/verify/route.ts
+++ b/app/api/auth/verify/route.ts
@@ -5,7 +5,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
-import { getClientIP } from '@/lib/admin/session';
+import { loginClientAddress } from '@/lib/auth/client-address';
import { reserveVerifyProbe, type BudgetRefund } from '@/lib/auth/verify-budget';
import { isStoreUnavailable } from '@/lib/auth/store-unavailable';
@@ -83,7 +83,7 @@ export async function POST(request: NextRequest) {
// Wrong passwords tried from here count against this server's address
// upstream; over budget the browser probes the JMAP server itself.
- const clientIp = getClientIP(request);
+ const clientIp = loginClientAddress(request, upstreamUrl);
let refund: BudgetRefund | null;
try {
refund = await reserveVerifyProbe(clientIp);
diff --git a/app/desktop.css b/app/desktop.css
new file mode 100644
index 000000000..7f1b55c74
--- /dev/null
+++ b/app/desktop.css
@@ -0,0 +1,76 @@
+/*
+ * The desktop app is an app, not a page. Everything here hangs on
+ * html[data-desktop], which the boot script sets when the preload bridge is
+ * there (lib/desktop/boot.ts), so a browser never matches any of it.
+ *
+ * UI chrome is listed one by one, instead of switching selection off for the
+ * whole page and listing what may stay on: a message, a contact's details or
+ * an event's notes must not stop being selectable because nobody thought of
+ * them. An HTML message is in its own frame; a plain-text one is in the page,
+ * and the rule at the end keeps it selectable wherever it sits.
+ */
+
+html[data-desktop],
+html[data-desktop] body {
+ overscroll-behavior: none;
+}
+
+html[data-desktop] :is(
+ button,
+ summary,
+ label,
+ nav,
+ [role="button"],
+ [role="navigation"],
+ [role="toolbar"],
+ [role="menu"],
+ [role="menubar"],
+ [role="menuitem"],
+ [role="menuitemcheckbox"],
+ [role="menuitemradio"],
+ [role="tab"],
+ [role="tablist"],
+ [role="listbox"],
+ [role="option"],
+ [role="tree"],
+ [role="treeitem"],
+ [role="row"],
+ [data-composer-header],
+ [data-mv-compose-titlebar],
+ [data-window-title-bar]
+) {
+ -webkit-user-select: none;
+ user-select: none;
+ cursor: default;
+}
+
+/* A field inside a label or a row is still a field. */
+html[data-desktop] :is(input, textarea, select, [contenteditable="true"], [contenteditable="plaintext-only"]) {
+ -webkit-user-select: text;
+ user-select: text;
+}
+
+html[data-desktop] :is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="button"], [type="submit"]), textarea, [contenteditable="true"], [contenteditable="plaintext-only"]) {
+ cursor: text;
+}
+
+/* No dragging an icon or a link by accident. What the app means to drag says so. */
+html[data-desktop] :is(img, a, svg) {
+ -webkit-user-drag: none;
+}
+
+html[data-desktop] [draggable="true"] {
+ -webkit-user-drag: element;
+}
+
+/* A plain-text message renders in the page, also inside rows and buttons. */
+html[data-desktop] [data-plugin-source="email-body"],
+html[data-desktop] [data-plugin-source="email-body"] * {
+ -webkit-user-select: text;
+ user-select: text;
+ cursor: text;
+}
+
+html[data-desktop] [data-plugin-source="email-body"] a {
+ cursor: pointer;
+}
diff --git a/app/globals.css b/app/globals.css
index 7cbfd50c8..212b080c2 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -568,6 +568,25 @@ optgroup {
/* Mobile Responsive Utilities */
+/*
+ * The top of the page in an installed app without a native title bar. 0
+ * without one: then --app-top-inset is exactly env(safe-area-inset-top), what
+ * the page roots used before. env() and not the display-mode media query,
+ * which is false in Electron with titleBarOverlay. Set at the root, so it
+ * holds from the first paint, for the boot skeletons too;
+ * lib/window-controls-overlay.ts keeps it up to date afterwards.
+ */
+:root {
+ --titlebar-inset: env(titlebar-area-height, 0px);
+ --app-top-inset: max(env(safe-area-inset-top), var(--titlebar-inset));
+}
+
+/* components/layout/window-title-bar.tsx: what the window is dragged by. */
+.window-title-bar {
+ app-region: drag;
+ -webkit-app-region: drag;
+}
+
/* Safe area insets for notched devices (iPhone X+, etc.) */
.safe-area-inset-top {
padding-top: env(safe-area-inset-top);
diff --git a/app/manifest.ts b/app/manifest.ts
index 53376bc12..c95478c26 100644
--- a/app/manifest.ts
+++ b/app/manifest.ts
@@ -35,7 +35,8 @@ type WebAppShareTarget = {
};
};
-type ExtendedManifest = MetadataRoute.Manifest & {
+type ExtendedManifest = Omit & {
+ display_override?: Array<"window-controls-overlay" | "standalone" | "minimal-ui" | "browser" | "fullscreen">;
protocol_handlers?: WebAppProtocolHandler[];
file_handlers?: WebAppFileHandler[];
share_target?: WebAppShareTarget;
@@ -124,6 +125,9 @@ export default async function manifest(): Promise {
start_url: withBase("/"),
scope: withBase("/"),
display: "standalone",
+ // The page draws up to the top of the window, next to the native controls
+ // (components/layout/window-title-bar.tsx). `display` is the fallback.
+ display_override: ["window-controls-overlay", "standalone"],
orientation: "portrait-primary",
theme_color: themeColor,
background_color: backgroundColor,
diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx
index 5343afe10..857ab630b 100644
--- a/components/admin/admin-shell.tsx
+++ b/components/admin/admin-shell.tsx
@@ -263,7 +263,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
const backHref = location.detail ? adminPath(location.page) : '/admin';
return (
-