mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 16:07:12 +00:00
feat: add an iOS inbox widget
This commit is contained in:
@@ -108,6 +108,8 @@ module.exports = {
|
|||||||
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
|
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
|
||||||
// TLS client certificates (#3): the iOS side of src/lib/client-cert.ts.
|
// TLS client certificates (#3): the iOS side of src/lib/client-cert.ts.
|
||||||
'./plugins/ios-client-cert',
|
'./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-
|
// Bundles the Involved sound calendar reminders (src/lib/calendar-
|
||||||
// notifications.ts) and new mail (plugins/ios-push) play on iOS.
|
// notifications.ts) and new mail (plugins/ios-push) play on iOS.
|
||||||
// Android has it in res/raw.
|
// Android has it in res/raw.
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { serial } from './serial';
|
|||||||
import { readView } from './state';
|
import { readView } from './state';
|
||||||
import type { WidgetSnapshot } from './snapshot';
|
import type { WidgetSnapshot } from './snapshot';
|
||||||
import { DARK, LIGHT, type WidgetPalette } from './theme';
|
import { DARK, LIGHT, type WidgetPalette } from './theme';
|
||||||
|
import { iosWidgetPlaced, writeIosWidget } from './ios';
|
||||||
import type { Layout, WidgetLocalState } from './layouts/types';
|
import type { Layout, WidgetLocalState } from './layouts/types';
|
||||||
import * as mail from './layouts/mail';
|
import * as mail from './layouts/mail';
|
||||||
import * as moreMail from './layouts/more-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.
|
* one drawn is the newest; calls made while one is still waiting share it.
|
||||||
*/
|
*/
|
||||||
export function redrawAll(): Promise<void> {
|
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;
|
if (queued) return queued;
|
||||||
const run = serial(async () => {
|
const run = serial(async () => {
|
||||||
queued = null;
|
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;
|
queued = run;
|
||||||
return 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. */
|
/** Whether any Bulwark widget is on a home screen; cached briefly. */
|
||||||
export async function hasPlacedWidgets(maxAgeMs = 60_000): Promise<boolean> {
|
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;
|
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 = Platform.OS === 'ios'
|
||||||
const value = counts.some((c) => c > 0);
|
? 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 };
|
placedCache = { at: Date.now(), value };
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-3
@@ -48,6 +48,12 @@ export function handleAppState(next: string): void {
|
|||||||
if (next !== 'background') return;
|
if (next !== 'background') return;
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
timer = null;
|
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;
|
const native = NativeModules.BulwarkWidgets as { refreshInBackground?: () => Promise<boolean> } | undefined;
|
||||||
void (native?.refreshInBackground?.() ?? Promise.resolve(false))
|
void (native?.refreshInBackground?.() ?? Promise.resolve(false))
|
||||||
.catch(() => 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 {
|
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 { useEmailStore } = require('../stores/email-store') as typeof import('../stores/email-store');
|
||||||
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-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');
|
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. */
|
/** Wipe what the widgets show after the last account signed out. */
|
||||||
export async function signOutWidgets(): Promise<void> {
|
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.
|
// Signed out of every account, not merely offline or mid-switch.
|
||||||
if ((await readRegistry()).accounts.length > 0) return;
|
if ((await readRegistry()).accounts.length > 0) return;
|
||||||
await replaceAll({ ...emptySnapshot(), generatedAt: Date.now() });
|
await replaceAll({ ...emptySnapshot(), generatedAt: Date.now() });
|
||||||
|
|||||||
Reference in New Issue
Block a user