mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
feat: add an iOS inbox widget
This commit is contained in:
@@ -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<InboxEntry>) -> 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Exposes BulwarkWidgetKit.swift to React Native as NativeModules.BulwarkWidgetKit.
|
||||
|
||||
#import <React/RCTBridgeModule.h>
|
||||
|
||||
@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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Bulwark Mail</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${version}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${buildNumber}</string>
|
||||
<key>BulwarkKeychainGroup</key>
|
||||
<string>$(DEVELOPMENT_TEAM).${SHARED_GROUP_SUFFIX}</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
function entitlementsPlist() {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)${SHARED_GROUP_SUFFIX}</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
Reference in New Issue
Block a user