mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 08:06:59 +00:00
feat: add an iOS notification extension that shows sender and subject
This commit is contained in:
@@ -22,6 +22,13 @@ COMMIT = COMMIT.slice(0, 7);
|
||||
// fall back to 1 (never uploaded).
|
||||
const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1';
|
||||
|
||||
// Apple team: prefixes the keychain groups the app shares with its
|
||||
// notification service extension (plugins/ios-push). Not a secret.
|
||||
const APPLE_TEAM_ID = process.env.APPLE_TEAM_ID || '8ZANF9LQ39';
|
||||
// Which APNs host the device tokens belong to: TestFlight and App Store builds
|
||||
// use production, simulators and development builds the sandbox.
|
||||
const APS_ENVIRONMENT = process.env.APS_ENVIRONMENT === 'development' ? 'development' : 'production';
|
||||
|
||||
module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
@@ -96,12 +103,17 @@ module.exports = {
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
|
||||
],
|
||||
extra: {
|
||||
commit: COMMIT,
|
||||
// Settings → About → Source code. A modified build sets SOURCE_CODE_URL
|
||||
// to where its own source is (AGPL §13); the default is upstream.
|
||||
sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native',
|
||||
// src/lib/ios-push-shared.ts
|
||||
iosSharedKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.shared`,
|
||||
iosAppKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.mobile`,
|
||||
apnsEnvironment: APS_ENVIRONMENT === 'development' ? 'sandbox' : 'production',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import Foundation
|
||||
|
||||
/// The little JMAP the extension needs: who sent the new messages and what
|
||||
/// they are about, plus the inbox's unread count for the badge.
|
||||
struct PushedEmail {
|
||||
let id: String
|
||||
let threadId: String
|
||||
let sender: String
|
||||
let subject: String
|
||||
let preview: String
|
||||
}
|
||||
|
||||
struct JmapFetchResult {
|
||||
let emails: [PushedEmail]
|
||||
let inboxUnread: Int?
|
||||
}
|
||||
|
||||
enum JmapError: Error {
|
||||
case noCredentials
|
||||
case http(Int)
|
||||
case badResponse
|
||||
}
|
||||
|
||||
/// Credentials as the app stores them (src/api/jmap-client.ts StoredCredentials).
|
||||
struct StoredCredentials {
|
||||
var json: [String: Any]
|
||||
|
||||
var username: String { json["username"] as? String ?? "" }
|
||||
var password: String { json["password"] as? String ?? "" }
|
||||
var accessToken: String? { json["accessToken"] as? String }
|
||||
var refreshToken: String? { json["refreshToken"] as? String }
|
||||
var tokenEndpoint: String? { json["tokenEndpoint"] as? String }
|
||||
var clientId: String? { json["clientId"] as? String }
|
||||
/// Milliseconds since 1970, like Date.now() in the app.
|
||||
var expiresAt: Double? { (json["expiresAt"] as? NSNumber)?.doubleValue }
|
||||
}
|
||||
|
||||
enum Jmap {
|
||||
static let session: URLSession = {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 12
|
||||
config.timeoutIntervalForResource = 20
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
/// The Authorization header for these credentials, refreshing an OAuth
|
||||
/// access token that is about to expire. A refreshed bundle is written back
|
||||
/// to the shared keychain item so the app picks it up (and doesn't refresh
|
||||
/// again with a refresh token the server has already replaced).
|
||||
static func authorization(credentialsKey: String) async throws -> String {
|
||||
guard let json = SharedKeychain.readJSON(credentialsKey) else { throw JmapError.noCredentials }
|
||||
var creds = StoredCredentials(json: json)
|
||||
if let token = creds.accessToken {
|
||||
let nowMs = Date().timeIntervalSince1970 * 1000
|
||||
if let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000,
|
||||
let refresh = creds.refreshToken, let endpoint = creds.tokenEndpoint, let clientId = creds.clientId,
|
||||
let url = URL(string: endpoint) {
|
||||
if let fresh = try? await refreshToken(url: url, refreshToken: refresh, clientId: clientId) {
|
||||
creds.json["accessToken"] = fresh.accessToken
|
||||
if let newRefresh = fresh.refreshToken { creds.json["refreshToken"] = newRefresh }
|
||||
if let expiresIn = fresh.expiresIn { creds.json["expiresAt"] = nowMs + expiresIn * 1000 }
|
||||
SharedKeychain.update(credentialsKey, json: creds.json)
|
||||
return "Bearer \(fresh.accessToken)"
|
||||
}
|
||||
}
|
||||
return "Bearer \(token)"
|
||||
}
|
||||
let basic = Data("\(creds.username):\(creds.password)".utf8).base64EncodedString()
|
||||
return "Basic \(basic)"
|
||||
}
|
||||
|
||||
struct FreshTokens {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresIn: Double?
|
||||
}
|
||||
|
||||
static func refreshToken(url: URL, refreshToken: String, clientId: String) async throws -> FreshTokens {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
var form = URLComponents()
|
||||
form.queryItems = [
|
||||
URLQueryItem(name: "grant_type", value: "refresh_token"),
|
||||
URLQueryItem(name: "refresh_token", value: refreshToken),
|
||||
URLQueryItem(name: "client_id", value: clientId),
|
||||
]
|
||||
request.httpBody = form.percentEncodedQuery?.data(using: .utf8)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
|
||||
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
|
||||
guard let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let access = body["access_token"] as? String else { throw JmapError.badResponse }
|
||||
return FreshTokens(
|
||||
accessToken: access,
|
||||
refreshToken: body["refresh_token"] as? String,
|
||||
expiresIn: (body["expires_in"] as? NSNumber)?.doubleValue
|
||||
)
|
||||
}
|
||||
|
||||
static func fetch(apiUrl: URL, accountId: String, emailIds: [String], authorization: String) async throws -> JmapFetchResult {
|
||||
var request = URLRequest(url: apiUrl)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(authorization, forHTTPHeaderField: "Authorization")
|
||||
let body: [String: Any] = [
|
||||
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
"methodCalls": [
|
||||
["Email/get", [
|
||||
"accountId": accountId,
|
||||
"ids": Array(emailIds.prefix(5)),
|
||||
"properties": ["threadId", "from", "subject", "preview"],
|
||||
], "0"],
|
||||
["Mailbox/query", ["accountId": accountId, "filter": ["role": "inbox"]], "1"],
|
||||
["Mailbox/get", [
|
||||
"accountId": accountId,
|
||||
"#ids": ["resultOf": "1", "name": "Mailbox/query", "path": "/ids"],
|
||||
"properties": ["unreadEmails"],
|
||||
], "2"],
|
||||
],
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
|
||||
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
|
||||
guard let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let responses = root["methodResponses"] as? [[Any]] else { throw JmapError.badResponse }
|
||||
|
||||
var emails: [PushedEmail] = []
|
||||
var inboxUnread: Int?
|
||||
for response in responses where response.count >= 2 {
|
||||
guard let name = response[0] as? String, let args = response[1] as? [String: Any] else { continue }
|
||||
if name == "Email/get", let list = args["list"] as? [[String: Any]] {
|
||||
// Keep the order the push gave (newest first from the server's filter).
|
||||
// Stalwart can list an id twice; keep the first.
|
||||
let byId = Dictionary(list.compactMap { item -> (String, [String: Any])? in
|
||||
guard let id = item["id"] as? String else { return nil }
|
||||
return (id, item)
|
||||
}, uniquingKeysWith: { first, _ in first })
|
||||
for id in emailIds {
|
||||
guard let item = byId[id] else { continue }
|
||||
let from = (item["from"] as? [[String: Any]])?.first
|
||||
let name = (from?["name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
let sender = name ?? (from?["email"] as? String) ?? ""
|
||||
emails.append(PushedEmail(
|
||||
id: id,
|
||||
threadId: item["threadId"] as? String ?? "",
|
||||
sender: sender,
|
||||
subject: item["subject"] as? String ?? "",
|
||||
preview: item["preview"] as? String ?? ""
|
||||
))
|
||||
}
|
||||
} else if name == "Mailbox/get", let list = args["list"] as? [[String: Any]] {
|
||||
inboxUnread = (list.first?["unreadEmails"] as? NSNumber)?.intValue
|
||||
}
|
||||
}
|
||||
return JmapFetchResult(emails: emails, inboxUnread: inboxUnread)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
/// Turns the relay's content-blind push ("New message" plus ids) into a real
|
||||
/// notification: sender, subject and preview, fetched over JMAP on this
|
||||
/// device with the account's own credentials. Nothing about the message ever
|
||||
/// passes through the relay or Apple.
|
||||
///
|
||||
/// What the app shares through the keychain group (src/lib/ios-push-shared.ts):
|
||||
/// bulwark_push_account__<JMAP account id> {credentialsKey, apiUrl}
|
||||
/// bulwark_push_settings {badge, activeAccountId, strings{...}}
|
||||
/// <credentialsKey> the account's stored credentials
|
||||
/// If anything is missing or the server can't be reached, the relay's
|
||||
/// placeholder text is shown, so the user still learns there is new mail.
|
||||
final class NotificationService: UNNotificationServiceExtension {
|
||||
private var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
private var placeholder: UNMutableNotificationContent?
|
||||
private var work: Task<Void, Never>?
|
||||
|
||||
override func didReceive(
|
||||
_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
||||
) {
|
||||
self.contentHandler = contentHandler
|
||||
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
placeholder = content
|
||||
let push = request.content.userInfo["body"] as? [String: Any]
|
||||
work = Task { [weak self] in
|
||||
let enriched = await NotificationService.enrich(content, push: push)
|
||||
self?.finish(enriched)
|
||||
}
|
||||
}
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
work?.cancel()
|
||||
finish(placeholder)
|
||||
}
|
||||
|
||||
private func finish(_ content: UNNotificationContent?) {
|
||||
guard let handler = contentHandler, let content else { return }
|
||||
contentHandler = nil
|
||||
handler(content)
|
||||
}
|
||||
|
||||
static func sanitize(_ id: String) -> String {
|
||||
String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" })
|
||||
}
|
||||
|
||||
static func enrich(_ content: UNMutableNotificationContent, push: [String: Any]?) async -> UNMutableNotificationContent {
|
||||
guard let push,
|
||||
let accountId = push["accountId"] as? String, !accountId.isEmpty,
|
||||
let account = SharedKeychain.readJSON("bulwark_push_account__\(sanitize(accountId))"),
|
||||
let credentialsKey = account["credentialsKey"] as? String,
|
||||
let apiString = account["apiUrl"] as? String,
|
||||
let apiUrl = URL(string: apiString) else {
|
||||
return content
|
||||
}
|
||||
let emailIds = push["emailIds"] as? [String] ?? []
|
||||
let settings = SharedKeychain.readJSON("bulwark_push_settings") ?? [:]
|
||||
let strings = settings["strings"] as? [String: String] ?? [:]
|
||||
|
||||
content.threadIdentifier = accountId
|
||||
// The app reads userInfo.body when the notification is tapped.
|
||||
var body = push
|
||||
|
||||
do {
|
||||
let authorization = try await Jmap.authorization(credentialsKey: credentialsKey)
|
||||
let result = try await Jmap.fetch(apiUrl: apiUrl, accountId: accountId, emailIds: emailIds, authorization: authorization)
|
||||
|
||||
if let first = result.emails.first {
|
||||
content.title = first.sender
|
||||
content.subtitle = first.subject.isEmpty ? (strings["noSubject"] ?? "(no subject)") : first.subject
|
||||
content.body = first.preview
|
||||
if result.emails.count > 1 {
|
||||
let more = (strings["more"] ?? "and {count} more").replacingOccurrences(of: "{count}", with: String(result.emails.count - 1))
|
||||
content.body = first.preview.isEmpty ? more : "\(first.preview)\n\(more)"
|
||||
}
|
||||
body["emailId"] = first.id
|
||||
body["threadId"] = first.threadId
|
||||
}
|
||||
|
||||
// Badge: like the app, the active account's inbox unread count. A push
|
||||
// for another account leaves the badge as it is.
|
||||
if settings["badge"] as? Bool == true,
|
||||
settings["activeAccountId"] as? String == accountId,
|
||||
let unread = result.inboxUnread {
|
||||
content.badge = NSNumber(value: unread)
|
||||
}
|
||||
} catch {
|
||||
// Keep the placeholder.
|
||||
}
|
||||
var userInfo = content.userInfo
|
||||
userInfo["body"] = body
|
||||
content.userInfo = userInfo
|
||||
return content
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Items the app shares with this extension through a keychain access group
|
||||
/// (BulwarkKeychainGroup in Info.plist). The app writes them with
|
||||
/// expo-secure-store: generic passwords whose account and generic attributes
|
||||
/// are the UTF-8 key and whose service is "app:no-auth".
|
||||
enum SharedKeychain {
|
||||
static let service = "app:no-auth"
|
||||
|
||||
static var group: String? {
|
||||
Bundle.main.object(forInfoDictionaryKey: "BulwarkKeychainGroup") as? String
|
||||
}
|
||||
|
||||
static func read(_ key: String) -> Data? {
|
||||
guard let group else { return nil }
|
||||
let account = Data(key.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: group,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil }
|
||||
return item as? Data
|
||||
}
|
||||
|
||||
static func readJSON(_ key: String) -> [String: Any]? {
|
||||
guard let data = read(key) else { return nil }
|
||||
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||
}
|
||||
|
||||
/// Replaces the value of an existing item (refreshed OAuth tokens). Never
|
||||
/// creates one: the app owns which items exist.
|
||||
@discardableResult
|
||||
static func update(_ key: String, json: [String: Any]) -> Bool {
|
||||
guard let group, let data = try? JSONSerialization.data(withJSONObject: json) else { return false }
|
||||
let account = Data(key.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: group,
|
||||
]
|
||||
let attributes: [String: Any] = [kSecValueData as String: data]
|
||||
return SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecSuccess
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Config plugin: push notifications on iOS.
|
||||
//
|
||||
// - The app's entitlements: aps-environment, and a keychain group shared with
|
||||
// the notification service extension (the app's own group stays first, so
|
||||
// items saved before keep their group).
|
||||
// - A Notification Service Extension target ("NotificationService",
|
||||
// org.bulwarkmail.mobile.NotificationService). The relay's push carries
|
||||
// only ids; the extension fetches sender and subject over JMAP on the device
|
||||
// before iOS shows the notification. Its sources live next to this file and
|
||||
// are copied into ios/ at prebuild, like everything else in ios/.
|
||||
//
|
||||
// Options (app.config.js):
|
||||
// apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins)
|
||||
// teamId: Apple team id, for the shared keychain group
|
||||
// extensionProfile / appProfile: provisioning profile specifiers for the
|
||||
// Release configuration of the extension and the app (IOS_NSE_PROFILE and
|
||||
// IOS_APP_PROFILE env win). Set per target because xcodebuild's command
|
||||
// line applies a specifier to every target. Empty = leave unset.
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
withEntitlementsPlist,
|
||||
withXcodeProject,
|
||||
withDangerousMod,
|
||||
createRunOncePlugin,
|
||||
} = require('@expo/config-plugins');
|
||||
|
||||
const TARGET = 'NotificationService';
|
||||
const DEPLOYMENT_TARGET = '15.1';
|
||||
const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift'];
|
||||
|
||||
function sharedGroupSuffix() {
|
||||
return 'org.bulwarkmail.shared';
|
||||
}
|
||||
|
||||
function withAppEntitlements(config, { apsEnvironment }) {
|
||||
return withEntitlementsPlist(config, (cfg) => {
|
||||
const bundleId = cfg.ios?.bundleIdentifier;
|
||||
cfg.modResults['aps-environment'] = apsEnvironment;
|
||||
const groups = new Set(cfg.modResults['keychain-access-groups'] ?? []);
|
||||
groups.add(`$(AppIdentifierPrefix)${bundleId}`);
|
||||
groups.add(`$(AppIdentifierPrefix)${sharedGroupSuffix()}`);
|
||||
cfg.modResults['keychain-access-groups'] = [...groups];
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
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 notifications</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).${sharedGroupSuffix()}</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</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)${sharedGroupSuffix()}</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
function withExtensionFiles(config) {
|
||||
return withDangerousMod(config, [
|
||||
'ios',
|
||||
async (cfg) => {
|
||||
const dir = path.join(cfg.modRequest.platformProjectRoot, TARGET);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const file of SOURCE_FILES) {
|
||||
fs.copyFileSync(path.join(__dirname, 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 setReleaseProfiles(cfg, { appProfile, extensionProfile }) {
|
||||
if (!appProfile && !extensionProfile) return;
|
||||
const project = cfg.modResults;
|
||||
const appName = `"${cfg.modRequest.projectName}"`;
|
||||
const configs = project.pbxXCBuildConfigurationSection();
|
||||
for (const key of Object.keys(configs)) {
|
||||
const entry = configs[key];
|
||||
if (!entry.buildSettings || entry.name !== 'Release') continue;
|
||||
const name = entry.buildSettings.PRODUCT_NAME;
|
||||
const profile = name === `"${TARGET}"` ? extensionProfile
|
||||
: (name === appName || name === '"$(TARGET_NAME)"') && entry.buildSettings.PRODUCT_BUNDLE_IDENTIFIER ? appProfile
|
||||
: '';
|
||||
if (profile) entry.buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${profile}"`;
|
||||
}
|
||||
}
|
||||
|
||||
// One mod for creating the target and naming the profiles: separate
|
||||
// withXcodeProject mods don't run in the order they are registered.
|
||||
function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) {
|
||||
return withXcodeProject(config, (cfg) => {
|
||||
addExtensionTarget(cfg, { teamId });
|
||||
setReleaseProfiles(cfg, { appProfile, extensionProfile });
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
function addExtensionTarget(cfg, { teamId }) {
|
||||
const project = cfg.modResults;
|
||||
if (project.pbxTargetByName(TARGET)) return;
|
||||
|
||||
// addTarget expects these sections; a fresh Expo project may not have them.
|
||||
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,
|
||||
);
|
||||
// Hang the group off the project's main group.
|
||||
const mainGroupId = project.getFirstProject().firstProject.mainGroup;
|
||||
project.addToPbxGroup(group.uuid, mainGroupId);
|
||||
|
||||
const bundleId = `${cfg.ios.bundleIdentifier}.${TARGET}`;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function withIosPush(config, options = {}) {
|
||||
const apsEnvironment = process.env.APS_ENVIRONMENT || options.apsEnvironment || 'production';
|
||||
const teamId = process.env.APPLE_TEAM_ID || options.teamId || '';
|
||||
const extensionProfile = process.env.IOS_NSE_PROFILE || options.extensionProfile || '';
|
||||
const appProfile = process.env.IOS_APP_PROFILE || options.appProfile || '';
|
||||
config = withAppEntitlements(config, { apsEnvironment });
|
||||
config = withExtensionFiles(config);
|
||||
config = withExtensionTarget(config, { teamId, appProfile, extensionProfile });
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = createRunOncePlugin(withIosPush, 'bulwark-ios-push', '1.0.0');
|
||||
Reference in New Issue
Block a user