feat: add an iOS inbox widget

This commit is contained in:
Linus Rath
2026-10-31 15:29:52 +01:00
parent ba150060cf
commit fbe5457604
9 changed files with 768 additions and 8 deletions
+163
View File
@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
// The iOS inbox widget (WidgetKit, plugins/ios-widget). It can't run JS: the
// app writes it a finished snapshot into the keychain group it shares with
// its extensions and asks WidgetKit to redraw.
const rn = vi.hoisted(() => ({
Platform: { OS: 'ios' },
AppState: { addEventListener: () => ({ remove: () => undefined }) },
NativeModules: {} as Record<string, unknown>,
}));
vi.mock('react-native', () => rn);
vi.mock('react-native-android-widget', () => ({
FlexWidget: () => null,
OverlapWidget: () => null,
SvgWidget: () => null,
TextWidget: () => null,
ImageWidget: () => null,
getWidgetInfo: vi.fn(async () => []),
requestWidgetUpdate: vi.fn(async () => undefined),
}));
vi.mock('../../lib/ios-push-shared', () => ({
sharedStoreOptions: () => ({ accessGroup: 'TEAM.org.bulwarkmail.shared', keychainAccessible: 1 }),
}));
import * as SecureStore from 'expo-secure-store';
import { IOS_WIDGET_KEY, iosWidgetSnapshot } from '../ios';
import { redrawAll, hasPlacedWidgets, invalidatePlacedCache } from '../render';
import { handleAppState } from '../sync';
import { saveSnapshot } from '../snapshot';
import { links } from '../clicks';
import { PREVIEW_NOW, sampleSnapshot } from '../sample-snapshot';
const ROOT = path.resolve(__dirname, '../../..');
const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8');
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
describe('the snapshot the iOS widget draws', () => {
const s = sampleSnapshot(PREVIEW_NOW);
it('carries the inbox unread count and the newest messages with their deep links', () => {
const out = iosWidgetSnapshot(s);
const inbox = s.mail.folders.find((f) => f.role === 'inbox');
expect(out.unread).toBe(inbox?.unread);
expect(out.messages.length).toBe(Math.min(6, s.mail.inbox.length));
expect(out.messages[0].id).toBe(s.mail.inbox[0].id);
expect(out.messages[0].url).toBe(links.message(s.mail.inbox[0]));
expect(out.messages[0].url.startsWith('bulwarkmobile://mail/message/')).toBe(true);
expect(out.inboxUrl).toBe('bulwarkmobile://mail/folder/inbox');
expect(out.strings.inbox).toBe('Inbox');
});
it('names the account only when there is more than one', () => {
expect(iosWidgetSnapshot({ ...s, accounts: s.accounts.slice(0, 1) }).account).toBeNull();
const two = [{ id: 'a', label: 'ada@example.org', color: '#000000', unread: 1 }, { id: 'b', label: 'bea@example.org', color: '#000000', unread: 0 }];
expect(iosWidgetSnapshot({ ...s, accounts: two, activeAccountId: 'b' }).account).toBe('bea@example.org');
});
it('fills in a missing subject', () => {
const blank = { ...s, mail: { ...s.mail, inbox: [{ ...s.mail.inbox[0], subject: '' }] } };
expect(iosWidgetSnapshot(blank).messages[0].subject).toBe('(no subject)');
});
});
describe('the app hands the widget its data on iOS', () => {
const reloadAll = vi.fn(async () => undefined);
const placedCount = vi.fn(async () => 1);
beforeEach(async () => {
vi.mocked(SecureStore.setItemAsync).mockClear();
reloadAll.mockClear();
rn.NativeModules.BulwarkWidgetKit = { reloadAll, placedCount };
invalidatePlacedCache();
await saveSnapshot(sampleSnapshot(PREVIEW_NOW));
});
afterEach(() => {
delete rn.NativeModules.BulwarkWidgetKit;
});
it('writes the snapshot to the shared keychain group and reloads the widget', async () => {
await redrawAll();
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
IOS_WIDGET_KEY,
expect.any(String),
expect.objectContaining({ accessGroup: 'TEAM.org.bulwarkmail.shared' }),
);
const written = JSON.parse(vi.mocked(SecureStore.setItemAsync).mock.calls[0][1] as string);
expect(written.messages.length).toBeGreaterThan(0);
expect(reloadAll).toHaveBeenCalledTimes(1);
});
it('asks WidgetKit whether a widget is placed', async () => {
expect(await hasPlacedWidgets()).toBe(true);
placedCount.mockResolvedValueOnce(0);
invalidatePlacedCache();
expect(await hasPlacedWidgets()).toBe(false);
});
it('hands the widget what the app has when it goes to the background', async () => {
handleAppState('background');
await flush();
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(IOS_WIDGET_KEY, expect.any(String), expect.anything());
});
});
describe('the WidgetKit side', () => {
const swift = read('plugins/ios-widget/BulwarkWidget.swift');
const kit = read('plugins/ios-widget/BulwarkWidgetKit.swift');
const bridge = read('plugins/ios-widget/BulwarkWidgetKit.m');
const plugin = createRequire(__filename)(path.join(ROOT, 'plugins/ios-widget/index.js')) as {
internals: {
includeWidget(o: { appProfile: string; widgetProfile: string }): boolean;
infoPlist(o: { version: string; buildNumber: string }): string;
entitlementsPlist(): string;
SOURCE_FILES: string[];
APP_FILES: string[];
};
};
it('reads the key the app writes, with the fields it writes', () => {
expect(swift).toContain(`SharedKeychain.read("${IOS_WIDGET_KEY}")`);
const fields = Object.keys(iosWidgetSnapshot(sampleSnapshot(PREVIEW_NOW)));
for (const field of fields) expect(swift).toMatch(new RegExp(`let ${field}: `));
for (const field of Object.keys(iosWidgetSnapshot(sampleSnapshot(PREVIEW_NOW)).messages[0])) {
expect(swift).toMatch(new RegExp(`let ${field}: `));
}
});
it('opens a tapped message through its deep link', () => {
expect(swift).toContain('Link(destination: url)');
expect(swift).toContain('.widgetURL(');
});
it('reloads the widget kind the extension declares', () => {
expect(swift).toContain('let inboxWidgetKind = "BulwarkInbox"');
expect(kit).toContain('static let inboxKind = "BulwarkInbox"');
expect(bridge).toContain('RCT_EXTERN_MODULE(BulwarkWidgetKit, NSObject)');
expect(bridge).toContain('RCT_EXTERN_METHOD(reloadAll:');
expect(bridge).toContain('RCT_EXTERN_METHOD(placedCount:');
});
it('builds a WidgetKit extension in the shared keychain group', () => {
const { internals } = plugin;
expect(internals.infoPlist({ version: '1.0.0', buildNumber: '7' })).toContain('<string>com.apple.widgetkit-extension</string>');
expect(internals.entitlementsPlist()).toContain('$(AppIdentifierPrefix)org.bulwarkmail.shared');
expect(internals.SOURCE_FILES).toEqual(['BulwarkWidget.swift', 'SharedKeychain.swift']);
expect(internals.APP_FILES).toEqual(['BulwarkWidgetKit.swift', 'BulwarkWidgetKit.m']);
expect(read('app.config.js')).toContain("'./plugins/ios-widget'");
});
it('leaves the widget out of a signed build that has no profile for it', () => {
const { includeWidget } = plugin.internals;
expect(includeWidget({ appProfile: '', widgetProfile: '' })).toBe(true);
expect(includeWidget({ appProfile: 'app-uuid', widgetProfile: 'widget-uuid' })).toBe(true);
expect(includeWidget({ appProfile: 'app-uuid', widgetProfile: '' })).toBe(false);
});
});
+116
View File
@@ -0,0 +1,116 @@
// The iOS inbox widget (WidgetKit, plugins/ios-widget). It can't run JS, so
// the app hands it a small finished snapshot: the active account's inbox
// unread count and newest messages, cut from the data the Android widgets
// draw (./snapshot.ts), with the texts already translated and each message's
// deep link (./clicks.ts) ready to open.
//
// The snapshot sits in the keychain group the app already shares with its
// extensions (src/lib/ios-push-shared.ts): an app group would need a new
// capability on every provisioning profile, the keychain group needs none.
// After each write the app asks WidgetKit to redraw (BulwarkWidgetKit).
import { NativeModules, Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import { sharedStoreOptions } from '../lib/ios-push-shared';
import { links } from './clicks';
import { makeFmt } from './format';
import { normalizeHex } from './theme';
import type { WidgetSnapshot } from './snapshot';
/** Read by plugins/ios-widget/BulwarkWidget.swift. */
export const IOS_WIDGET_KEY = 'bulwark_widget_inbox';
/** Messages a large widget can show; smaller ones take the first few. */
const MAX_MESSAGES = 6;
export interface IosWidgetMessage {
id: string;
from: string;
initials: string;
color: string;
subject: string;
preview: string;
receivedAt: number;
unread: boolean;
/** Opens the message in the app. */
url: string;
}
export interface IosWidgetSnapshot {
version: 1;
generatedAt: number;
signedIn: boolean;
theme: 'light' | 'dark' | 'system';
locale: string;
hour12: boolean;
/** The active account's label; null with a single account. */
account: string | null;
unread: number;
inboxUrl: string;
composeUrl: string;
messages: IosWidgetMessage[];
strings: { inbox: string; unread: string; empty: string; signedOut: string; noSubject: string };
}
export function iosWidgetSnapshot(s: WidgetSnapshot): IosWidgetSnapshot {
const f = makeFmt(s.locale, s.hour12);
const noSubject = f.t('widgets.mail.no_subject', '(no subject)');
const inbox = s.mail.folders.find((folder) => folder.role === 'inbox');
const account = s.accounts.length > 1 ? s.accounts.find((a) => a.id === s.activeAccountId)?.label ?? null : null;
return {
version: 1,
generatedAt: s.generatedAt,
signedIn: s.signedIn,
theme: s.theme,
locale: s.locale,
hour12: s.hour12,
account,
unread: inbox?.unread ?? 0,
inboxUrl: links.inbox(),
composeUrl: links.compose(),
messages: s.mail.inbox.slice(0, MAX_MESSAGES).map((m) => ({
id: m.id,
from: m.fromName || m.fromEmail,
initials: m.initials,
color: normalizeHex(m.color),
subject: m.subject || noSubject,
preview: m.preview,
receivedAt: m.receivedAt,
unread: m.unread,
url: links.message(m),
})),
strings: {
inbox: f.t('widgets.mail.inbox', 'Inbox'),
unread: f.t('widgets.mail.unread', 'unread'),
empty: f.t('widgets.mail.empty_inbox', 'Your inbox is empty'),
signedOut: f.t('widgets.state.signed_out', 'Sign in to Bulwark'),
noSubject,
},
};
}
type WidgetKitModule = { reloadAll?: () => Promise<void>; placedCount?: () => Promise<number> };
function widgetKit(): WidgetKitModule | undefined {
return NativeModules.BulwarkWidgetKit as WidgetKitModule | undefined;
}
/** Whether an inbox widget is on the home screen (or the module can't tell). */
export async function iosWidgetPlaced(): Promise<boolean> {
const count = widgetKit()?.placedCount;
if (!count) return false;
try {
return (await count()) > 0;
} catch {
return true;
}
}
/** Hand the widget what it shows and have WidgetKit redraw it. */
export async function writeIosWidget(s: WidgetSnapshot): Promise<void> {
if (Platform.OS !== 'ios') return;
const options = sharedStoreOptions();
if (!options) return;
await SecureStore.setItemAsync(IOS_WIDGET_KEY, JSON.stringify(iosWidgetSnapshot(s)), options);
await widgetKit()?.reloadAll?.().catch(() => undefined);
}
+9 -5
View File
@@ -16,6 +16,7 @@ import { serial } from './serial';
import { readView } from './state';
import type { WidgetSnapshot } from './snapshot';
import { DARK, LIGHT, type WidgetPalette } from './theme';
import { iosWidgetPlaced, writeIosWidget } from './ios';
import type { Layout, WidgetLocalState } from './layouts/types';
import * as mail from './layouts/mail';
import * as moreMail from './layouts/more-mail';
@@ -161,11 +162,13 @@ let queued: Promise<void> | null = null;
* one drawn is the newest; calls made while one is still waiting share it.
*/
export function redrawAll(): Promise<void> {
if (Platform.OS !== 'android') return Promise.resolve();
if (Platform.OS !== 'android' && Platform.OS !== 'ios') return Promise.resolve();
if (queued) return queued;
const run = serial(async () => {
queued = null;
await updateAllWidgets(await readView());
// iOS draws its WidgetKit widget itself from a snapshot handed over (./ios.ts).
if (Platform.OS === 'ios') await writeIosWidget(await readView());
else await updateAllWidgets(await readView());
});
queued = run;
return run;
@@ -180,10 +183,11 @@ let placedCache: { at: number; value: boolean } | null = null;
/** Whether any Bulwark widget is on a home screen; cached briefly. */
export async function hasPlacedWidgets(maxAgeMs = 60_000): Promise<boolean> {
if (Platform.OS !== 'android') return false;
if (Platform.OS !== 'android' && Platform.OS !== 'ios') return false;
if (placedCache && Date.now() - placedCache.at < maxAgeMs) return placedCache.value;
const counts = await Promise.all(WIDGET_NAMES.map((n) => getWidgetInfo(n).then((l) => l.length).catch(() => 0)));
const value = counts.some((c) => c > 0);
const value = Platform.OS === 'ios'
? await iosWidgetPlaced()
: (await Promise.all(WIDGET_NAMES.map((n) => getWidgetInfo(n).then((l) => l.length).catch(() => 0)))).some((c) => c > 0);
placedCache = { at: Date.now(), value };
return value;
}
+9 -3
View File
@@ -48,6 +48,12 @@ export function handleAppState(next: string): void {
if (next !== 'background') return;
if (timer) clearTimeout(timer);
timer = null;
// iOS has no headless task to fetch in: hand the WidgetKit widget what the
// app already has, which needs no network.
if (Platform.OS === 'ios') {
void redrawAll().catch(() => undefined);
return;
}
const native = NativeModules.BulwarkWidgets as { refreshInBackground?: () => Promise<boolean> } | undefined;
void (native?.refreshInBackground?.() ?? Promise.resolve(false))
.catch(() => false)
@@ -56,9 +62,9 @@ export function handleAppState(next: string): void {
});
}
/** Start following app state; returns the cleanup. No-op off Android. */
/** Start following app state; returns the cleanup. No-op off Android and iOS. */
export function startWidgetSync(): () => void {
if (Platform.OS !== 'android') return () => {};
if (Platform.OS !== 'android' && Platform.OS !== 'ios') return () => {};
const { useEmailStore } = require('../stores/email-store') as typeof import('../stores/email-store');
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
const { useSettingsStore } = require('../stores/settings-store') as typeof import('../stores/settings-store');
@@ -93,7 +99,7 @@ export function startWidgetSync(): () => void {
/** Wipe what the widgets show after the last account signed out. */
export async function signOutWidgets(): Promise<void> {
if (Platform.OS !== 'android') return;
if (Platform.OS !== 'android' && Platform.OS !== 'ios') return;
// Signed out of every account, not merely offline or mid-switch.
if ((await readRegistry()).accounts.length > 0) return;
await replaceAll({ ...emptySnapshot(), generatedAt: Date.now() });