diff --git a/app.config.js b/app.config.js index 0bc1c3e7..fc51e255 100644 --- a/app.config.js +++ b/app.config.js @@ -108,6 +108,8 @@ module.exports = { ['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }], // TLS client certificates (#3): the iOS side of src/lib/client-cert.ts. './plugins/ios-client-cert', + // The iOS inbox widget (WidgetKit); src/widgets/ios.ts feeds it. + ['./plugins/ios-widget', { teamId: APPLE_TEAM_ID }], // Bundles the Involved sound calendar reminders (src/lib/calendar- // notifications.ts) and new mail (plugins/ios-push) play on iOS. // Android has it in res/raw. diff --git a/plugins/ios-widget/BulwarkWidget.swift b/plugins/ios-widget/BulwarkWidget.swift new file mode 100644 index 00000000..f37d2e86 --- /dev/null +++ b/plugins/ios-widget/BulwarkWidget.swift @@ -0,0 +1,268 @@ +import SwiftUI +import WidgetKit + +/// The iOS inbox widget: the active account's inbox unread count and newest +/// messages. The app writes what it shows (src/widgets/ios.ts) into the +/// keychain group it shares with its extensions and reloads the widget after +/// each write; a tap opens the app on the message through its deep link. + +/// Kind the app reloads (BulwarkWidgetKit.swift). +let inboxWidgetKind = "BulwarkInbox" + +/// src/widgets/ios.ts IosWidgetSnapshot. +struct InboxSnapshot: Decodable { + struct Message: Decodable, Identifiable { + let id: String + let from: String + let initials: String + let color: String + let subject: String + let preview: String + let receivedAt: Double + let unread: Bool + let url: String + } + + struct Strings: Decodable { + let inbox: String + let unread: String + let empty: String + let signedOut: String + let noSubject: String + } + + let version: Int + let generatedAt: Double + let signedIn: Bool + let theme: String + let locale: String + let hour12: Bool + let account: String? + let unread: Int + let inboxUrl: String + let composeUrl: String + let messages: [Message] + let strings: Strings + + /// IOS_WIDGET_KEY in src/widgets/ios.ts. + static func load() -> InboxSnapshot? { + guard let data = SharedKeychain.read("bulwark_widget_inbox") else { return nil } + return try? JSONDecoder().decode(InboxSnapshot.self, from: data) + } +} + +struct InboxEntry: TimelineEntry { + let date: Date + let snapshot: InboxSnapshot? +} + +struct InboxProvider: TimelineProvider { + func placeholder(in context: Context) -> InboxEntry { + InboxEntry(date: Date(), snapshot: nil) + } + + func getSnapshot(in context: Context, completion: @escaping (InboxEntry) -> Void) { + completion(InboxEntry(date: Date(), snapshot: InboxSnapshot.load())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = InboxEntry(date: Date(), snapshot: InboxSnapshot.load()) + // The app reloads the widget when it writes new data; this only keeps + // "today" and the times current. + completion(Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(30 * 60)))) + } +} + +/// The widget palette of src/widgets/theme.ts, roughly. +struct Palette { + let background: Color + let foreground: Color + let muted: Color + let accent: Color + + init(dark: Bool) { + background = dark ? Color(hex: "#18181b") : Color(hex: "#ffffff") + foreground = dark ? Color(hex: "#fafafa") : Color(hex: "#09090b") + muted = dark ? Color(hex: "#a1a1aa") : Color(hex: "#71717a") + accent = Color(hex: "#db2d54") + } +} + +extension Color { + init(hex: String) { + var value: UInt64 = 0 + Scanner(string: hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))).scanHexInt64(&value) + self.init( + red: Double((value >> 16) & 0xff) / 255, + green: Double((value >> 8) & 0xff) / 255, + blue: Double(value & 0xff) / 255 + ) + } +} + +extension View { + /// iOS 17 wants the background declared and adds the margins itself. + @ViewBuilder func widgetBackground(_ color: Color) -> some View { + if #available(iOS 17.0, *) { + containerBackground(for: .widget) { color } + } else { + padding(14).background(color) + } + } +} + +func listTime(_ ms: Double, locale: String, hour12: Bool) -> String { + let date = Date(timeIntervalSince1970: ms / 1000) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: locale) + if Calendar.current.isDateInToday(date) { + formatter.dateFormat = hour12 ? "h:mm a" : "HH:mm" + } else { + formatter.setLocalizedDateFormatFromTemplate("MMMd") + } + return formatter.string(from: date) +} + +struct MessageRow: View { + let message: InboxSnapshot.Message + let time: String + let palette: Palette + + var body: some View { + HStack(spacing: 8) { + ZStack { + Circle().fill(Color(hex: message.color)) + Text(message.initials) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.white) + } + .frame(width: 26, height: 26) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Text(message.from) + .font(.system(size: 13, weight: message.unread ? .semibold : .regular)) + .foregroundColor(palette.foreground) + .lineLimit(1) + Spacer(minLength: 4) + Text(time) + .font(.system(size: 11)) + .foregroundColor(message.unread ? palette.accent : palette.muted) + } + Text(message.subject) + .font(.system(size: 12)) + .foregroundColor(message.unread ? palette.foreground : palette.muted) + .lineLimit(1) + } + } + } +} + +struct InboxWidgetView: View { + @Environment(\.widgetFamily) private var family + @Environment(\.colorScheme) private var systemScheme + let entry: InboxEntry + + private var dark: Bool { + switch entry.snapshot?.theme { + case "dark": return true + case "light": return false + default: return systemScheme == .dark + } + } + + private var rows: Int { + switch family { + case .systemSmall: return 1 + case .systemMedium: return 2 + default: return 6 + } + } + + var body: some View { + let palette = Palette(dark: dark) + content(palette) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .widgetBackground(palette.background) + .widgetURL(URL(string: entry.snapshot?.inboxUrl ?? "bulwarkmobile://mail/folder/inbox")) + } + + @ViewBuilder private func content(_ palette: Palette) -> some View { + if let snapshot = entry.snapshot, snapshot.signedIn { + VStack(alignment: .leading, spacing: family == .systemSmall ? 6 : 8) { + header(snapshot, palette) + if snapshot.messages.isEmpty { + Spacer(minLength: 0) + Text(snapshot.strings.empty) + .font(.system(size: 13)) + .foregroundColor(palette.muted) + Spacer(minLength: 0) + } else { + ForEach(snapshot.messages.prefix(rows)) { message in + let row = MessageRow( + message: message, + time: listTime(message.receivedAt, locale: snapshot.locale, hour12: snapshot.hour12), + palette: palette + ) + // A small widget opens what it shows through widgetURL alone. + if family != .systemSmall, let url = URL(string: message.url) { + Link(destination: url) { row } + } else { + row + } + } + Spacer(minLength: 0) + } + } + } else { + VStack(alignment: .leading, spacing: 6) { + Text("Bulwark Mail") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(palette.foreground) + Text(entry.snapshot?.strings.signedOut ?? "Open Bulwark to load your inbox.") + .font(.system(size: 13)) + .foregroundColor(palette.muted) + } + } + } + + private func header(_ snapshot: InboxSnapshot, _ palette: Palette) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + VStack(alignment: .leading, spacing: 0) { + Text(snapshot.strings.inbox) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(palette.foreground) + if let account = snapshot.account, family != .systemSmall { + Text(account) + .font(.system(size: 11)) + .foregroundColor(palette.muted) + .lineLimit(1) + } + } + Spacer(minLength: 4) + if snapshot.unread > 0 { + Text("\(snapshot.unread)") + .font(.system(size: family == .systemSmall ? 22 : 15, weight: .bold)) + .foregroundColor(palette.accent) + .accessibilityLabel("\(snapshot.unread) \(snapshot.strings.unread)") + } + } + } +} + +struct InboxWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: inboxWidgetKind, provider: InboxProvider()) { entry in + InboxWidgetView(entry: entry) + } + .configurationDisplayName("Inbox") + .description("Unread mail and the newest messages.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + +@main +struct BulwarkWidgets: WidgetBundle { + var body: some Widget { + InboxWidget() + } +} diff --git a/plugins/ios-widget/BulwarkWidgetKit.m b/plugins/ios-widget/BulwarkWidgetKit.m new file mode 100644 index 00000000..829c02bf --- /dev/null +++ b/plugins/ios-widget/BulwarkWidgetKit.m @@ -0,0 +1,10 @@ +// Exposes BulwarkWidgetKit.swift to React Native as NativeModules.BulwarkWidgetKit. + +#import + +@interface RCT_EXTERN_MODULE(BulwarkWidgetKit, NSObject) + +RCT_EXTERN_METHOD(reloadAll:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(placedCount:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) + +@end diff --git a/plugins/ios-widget/BulwarkWidgetKit.swift b/plugins/ios-widget/BulwarkWidgetKit.swift new file mode 100644 index 00000000..9ca625fc --- /dev/null +++ b/plugins/ios-widget/BulwarkWidgetKit.swift @@ -0,0 +1,32 @@ +import Foundation +import React +import WidgetKit + +/// Lets the app redraw its WidgetKit widget (plugins/ios-widget) after it +/// wrote new data (src/widgets/ios.ts), and ask whether one is placed at all, +/// so it doesn't build a snapshot nobody sees. +@objc(BulwarkWidgetKit) +final class BulwarkWidgetKit: NSObject { + /// inboxWidgetKind in BulwarkWidget.swift. + static let inboxKind = "BulwarkInbox" + + @objc static func requiresMainQueueSetup() -> Bool { false } + + @objc(reloadAll:rejecter:) + func reloadAll(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { + WidgetCenter.shared.reloadTimelines(ofKind: BulwarkWidgetKit.inboxKind) + resolve(nil) + } + + @objc(placedCount:rejecter:) + func placedCount(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { + WidgetCenter.shared.getCurrentConfigurations { result in + switch result { + case .success(let widgets): + resolve(widgets.filter { $0.kind == BulwarkWidgetKit.inboxKind }.count) + case .failure(let error): + reject("widgetkit", error.localizedDescription, error) + } + } + } +} diff --git a/plugins/ios-widget/index.js b/plugins/ios-widget/index.js new file mode 100644 index 00000000..9cd5420a --- /dev/null +++ b/plugins/ios-widget/index.js @@ -0,0 +1,159 @@ +// Config plugin: the iOS inbox widget (WidgetKit). +// +// - A widget extension target ("BulwarkWidget", org.bulwarkmail.mobile.Widget) +// built from BulwarkWidget.swift, plus SharedKeychain.swift from +// plugins/ios-push: the widget reads what it shows from the keychain group +// the app shares with its extensions (src/widgets/ios.ts writes it). No app +// group: that would need a new capability on every provisioning profile. +// - The BulwarkWidgetKit native module in the app (BulwarkWidgetKit.swift/.m), +// which reloads the widget after the app wrote new data. +// +// Signing: a signed build names a profile per target. When the app's profile +// is given (IOS_APP_PROFILE, the release workflow) but no widget profile +// (IOS_WIDGET_PROFILE), the widget target is left out so the archive still +// signs; the simulator and unsigned builds always include it. + +const fs = require('node:fs'); +const path = require('node:path'); +const { withDangerousMod, withXcodeProject, createRunOncePlugin } = require('@expo/config-plugins'); +const { withAppSources } = require('../ios-app-sources'); + +const TARGET = 'BulwarkWidget'; +const DEPLOYMENT_TARGET = '15.1'; +const SHARED_GROUP_SUFFIX = 'org.bulwarkmail.shared'; +// The widget's own sources, and the ones it borrows from plugins/ios-push. +const WIDGET_FILES = ['BulwarkWidget.swift']; +const BORROWED_FILES = ['SharedKeychain.swift']; +const SOURCE_FILES = [...WIDGET_FILES, ...BORROWED_FILES]; +const APP_FILES = ['BulwarkWidgetKit.swift', 'BulwarkWidgetKit.m']; + +function infoPlist({ version, buildNumber }) { + return ` + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Bulwark Mail + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + ${version} + CFBundleVersion + ${buildNumber} + BulwarkKeychainGroup + $(DEVELOPMENT_TEAM).${SHARED_GROUP_SUFFIX} + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + +`; +} + +function entitlementsPlist() { + return ` + + + + keychain-access-groups + + $(AppIdentifierPrefix)${SHARED_GROUP_SUFFIX} + + + +`; +} + +/** Whether this build gets the widget target (see the signing note above). */ +function includeWidget({ appProfile, widgetProfile }) { + return !appProfile || !!widgetProfile; +} + +function withWidgetFiles(config) { + return withDangerousMod(config, [ + 'ios', + async (cfg) => { + const dir = path.join(cfg.modRequest.platformProjectRoot, TARGET); + fs.mkdirSync(dir, { recursive: true }); + for (const file of WIDGET_FILES) fs.copyFileSync(path.join(__dirname, file), path.join(dir, file)); + for (const file of BORROWED_FILES) fs.copyFileSync(path.join(__dirname, '../ios-push', file), path.join(dir, file)); + fs.writeFileSync( + path.join(dir, `${TARGET}-Info.plist`), + infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }), + ); + fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist()); + return cfg; + }, + ]); +} + +function addWidgetTarget(cfg, { teamId, widgetProfile }) { + const project = cfg.modResults; + if (project.pbxTargetByName(TARGET)) return; + + const objects = project.hash.project.objects; + objects.PBXTargetDependency = objects.PBXTargetDependency || {}; + objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {}; + + const group = project.addPbxGroup( + [...SOURCE_FILES, `${TARGET}-Info.plist`, `${TARGET}.entitlements`], + TARGET, + TARGET, + ); + const mainGroupId = project.getFirstProject().firstProject.mainGroup; + project.addToPbxGroup(group.uuid, mainGroupId); + + const bundleId = `${cfg.ios.bundleIdentifier}.Widget`; + const target = project.addTarget(TARGET, 'app_extension', TARGET, bundleId); + project.addBuildPhase(SOURCE_FILES, 'PBXSourcesBuildPhase', 'Sources', target.uuid); + project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid); + project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid); + + const configs = project.pbxXCBuildConfigurationSection(); + for (const key of Object.keys(configs)) { + const entry = configs[key]; + if (!entry.buildSettings || entry.buildSettings.PRODUCT_NAME !== `"${TARGET}"`) continue; + const s = entry.buildSettings; + s.CODE_SIGN_ENTITLEMENTS = `${TARGET}/${TARGET}.entitlements`; + s.IPHONEOS_DEPLOYMENT_TARGET = DEPLOYMENT_TARGET; + s.TARGETED_DEVICE_FAMILY = '"1,2"'; + s.SWIFT_VERSION = '5.0'; + s.CLANG_ENABLE_MODULES = 'YES'; + s.GENERATE_INFOPLIST_FILE = 'NO'; + if (teamId) s.DEVELOPMENT_TEAM = teamId; + if (widgetProfile && entry.name === 'Release') s.PROVISIONING_PROFILE_SPECIFIER = `"${widgetProfile}"`; + } +} + +function withIosWidget(config, options = {}) { + const teamId = process.env.APPLE_TEAM_ID || options.teamId || ''; + const appProfile = process.env.IOS_APP_PROFILE || options.appProfile || ''; + const widgetProfile = process.env.IOS_WIDGET_PROFILE || options.widgetProfile || ''; + config = withAppSources(config, { dir: __dirname, files: APP_FILES }); + if (!includeWidget({ appProfile, widgetProfile })) { + console.warn('plugins/ios-widget: IOS_APP_PROFILE is set but IOS_WIDGET_PROFILE is not; building without the widget.'); + return config; + } + config = withWidgetFiles(config); + config = withXcodeProject(config, (cfg) => { + addWidgetTarget(cfg, { teamId, widgetProfile }); + return cfg; + }); + return config; +} + +module.exports = createRunOncePlugin(withIosWidget, 'bulwark-ios-widget', '1.0.0'); +// For the tests (src/widgets/__tests__/ios-widget.test.ts). +module.exports.internals = { includeWidget, infoPlist, entitlementsPlist, SOURCE_FILES, APP_FILES, TARGET }; diff --git a/src/widgets/__tests__/ios-widget.test.ts b/src/widgets/__tests__/ios-widget.test.ts new file mode 100644 index 00000000..e9a63eb4 --- /dev/null +++ b/src/widgets/__tests__/ios-widget.test.ts @@ -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, +})); +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('com.apple.widgetkit-extension'); + 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); + }); +}); diff --git a/src/widgets/ios.ts b/src/widgets/ios.ts new file mode 100644 index 00000000..3ce458cd --- /dev/null +++ b/src/widgets/ios.ts @@ -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; placedCount?: () => Promise }; + +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 { + 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 { + 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); +} diff --git a/src/widgets/render.tsx b/src/widgets/render.tsx index 308f82ff..7bd9141d 100644 --- a/src/widgets/render.tsx +++ b/src/widgets/render.tsx @@ -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 | null = null; * one drawn is the newest; calls made while one is still waiting share it. */ export function redrawAll(): Promise { - 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 { - 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; } diff --git a/src/widgets/sync.ts b/src/widgets/sync.ts index 863c35c5..efc7e9f5 100644 --- a/src/widgets/sync.ts +++ b/src/widgets/sync.ts @@ -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 } | 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 { - 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() });