Add local iOS release pipeline, fix shortcuts, and improve invoice UX.
Enable App Store builds without EAS, iOS 18 App Intents plugins, and signing fixes for distribution export. Add mobile invoice PDF preview, compact line items, and more reliable shortcut deep-link handling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
enum BeenVoiceIntentHelpers {
|
||||
@MainActor
|
||||
static func openDeepLink(_ url: URL) {
|
||||
EnvironmentValues().openURL(url)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,37 @@
|
||||
import AppIntents
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
@available(iOS 18.0, *)
|
||||
struct BeenVoiceShortcuts: AppShortcutsProvider {
|
||||
@AppShortcutsBuilder
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
[
|
||||
AppShortcut(
|
||||
intent: ClockInIntent(),
|
||||
phrases: [
|
||||
"Clock in with \(.applicationName)",
|
||||
"Start timer in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Clock In",
|
||||
systemImageName: "play.circle.fill"
|
||||
),
|
||||
AppShortcut(
|
||||
intent: ClockOutIntent(),
|
||||
phrases: [
|
||||
"Clock out in \(.applicationName)",
|
||||
"Stop timer in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Clock Out",
|
||||
systemImageName: "stop.circle.fill"
|
||||
),
|
||||
AppShortcut(
|
||||
intent: OpenTimerIntent(),
|
||||
phrases: [
|
||||
"Open time clock in \(.applicationName)",
|
||||
"Open timer in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Time Clock",
|
||||
systemImageName: "timer"
|
||||
),
|
||||
]
|
||||
AppShortcut(
|
||||
intent: ClockInIntent(),
|
||||
phrases: [
|
||||
"Clock in with \(.applicationName)",
|
||||
"Start timer in \(.applicationName)",
|
||||
"Start tracking time in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Clock In",
|
||||
systemImageName: "play.circle.fill"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: ClockOutIntent(),
|
||||
phrases: [
|
||||
"Clock out in \(.applicationName)",
|
||||
"Stop timer in \(.applicationName)",
|
||||
"Stop tracking time in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Clock Out",
|
||||
systemImageName: "stop.circle.fill"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: OpenTimerIntent(),
|
||||
phrases: [
|
||||
"Open time clock in \(.applicationName)",
|
||||
"Open timer in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Time Clock",
|
||||
systemImageName: "timer"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import AppIntents
|
||||
import UIKit
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
@available(iOS 18.0, *)
|
||||
struct ClockInIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Clock In"
|
||||
static var description = IntentDescription("Start the beenvoice time clock with your last client.")
|
||||
static var openAppWhenRun: Bool = false
|
||||
static var openAppWhenRun: Bool = true
|
||||
|
||||
@Parameter(title: "Title")
|
||||
var title: String?
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
var components = URLComponents()
|
||||
components.scheme = "beenvoice"
|
||||
@@ -24,9 +24,7 @@ struct ClockInIntent: AppIntent {
|
||||
return .result()
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
BeenVoiceIntentHelpers.openDeepLink(url)
|
||||
|
||||
return .result()
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import AppIntents
|
||||
import UIKit
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
@available(iOS 18.0, *)
|
||||
struct ClockOutIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Clock Out"
|
||||
static var description = IntentDescription("Stop the running beenvoice timer and save your time.")
|
||||
static var openAppWhenRun: Bool = false
|
||||
static var openAppWhenRun: Bool = true
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let url = URL(string: "beenvoice://shortcuts/clock-out") else {
|
||||
return .result()
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
BeenVoiceIntentHelpers.openDeepLink(url)
|
||||
|
||||
return .result()
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import AppIntents
|
||||
import UIKit
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
@available(iOS 18.0, *)
|
||||
struct OpenTimerIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Open Time Clock"
|
||||
static var description = IntentDescription("Open the beenvoice time clock.")
|
||||
static var openAppWhenRun: Bool = false
|
||||
static var openAppWhenRun: Bool = true
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let url = URL(string: "beenvoice://timer") else {
|
||||
return .result()
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
BeenVoiceIntentHelpers.openDeepLink(url)
|
||||
|
||||
return .result()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const SWIFT_FILES = [
|
||||
"BeenVoiceIntentHelpers.swift",
|
||||
"ClockInIntent.swift",
|
||||
"ClockOutIntent.swift",
|
||||
"OpenTimerIntent.swift",
|
||||
@@ -25,8 +26,11 @@ function withAppIntents(config) {
|
||||
config = withDangerousMod(config, [
|
||||
"ios",
|
||||
async (config) => {
|
||||
const projectRoot = config.modRequest.projectRoot;
|
||||
const platformRoot = config.modRequest.platformProjectRoot;
|
||||
const projectName = IOSConfig.XcodeUtils.getProjectName(platformRoot);
|
||||
const projectName =
|
||||
config.modRequest.projectName ??
|
||||
IOSConfig.XcodeUtils.getProjectName(projectRoot);
|
||||
const targetDir = path.join(platformRoot, projectName);
|
||||
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
@@ -35,14 +39,50 @@ function withAppIntents(config) {
|
||||
fs.copyFileSync(path.join(appIntentsSource, file), path.join(targetDir, file));
|
||||
}
|
||||
|
||||
const appDelegatePath = path.join(targetDir, "AppDelegate.swift");
|
||||
if (fs.existsSync(appDelegatePath)) {
|
||||
let appDelegate = fs.readFileSync(appDelegatePath, "utf8");
|
||||
const marker = "BeenVoiceShortcuts.updateAppShortcutParameters";
|
||||
|
||||
if (!appDelegate.includes("import AppIntents")) {
|
||||
appDelegate = appDelegate.replace(
|
||||
"internal import Expo",
|
||||
"import AppIntents\ninternal import Expo",
|
||||
);
|
||||
}
|
||||
|
||||
if (appDelegate.includes(marker)) {
|
||||
appDelegate = appDelegate.replace(
|
||||
/if #available\(iOS 16\.0, \*\)/g,
|
||||
"if #available(iOS 18.0, *)",
|
||||
);
|
||||
} else {
|
||||
appDelegate = appDelegate.replace(
|
||||
"return super.application(application, didFinishLaunchingWithOptions: launchOptions)",
|
||||
`if #available(iOS 18.0, *) {
|
||||
Task {
|
||||
await BeenVoiceShortcuts.updateAppShortcutParameters()
|
||||
}
|
||||
}
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)`,
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(appDelegatePath, appDelegate);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
return withXcodeProject(config, (config) => {
|
||||
const project = config.modResults;
|
||||
const projectRoot = config.modRequest.projectRoot;
|
||||
const platformRoot = config.modRequest.platformProjectRoot;
|
||||
const projectName = IOSConfig.XcodeUtils.getProjectName(platformRoot);
|
||||
const projectName =
|
||||
config.modRequest.projectName ??
|
||||
IOSConfig.XcodeUtils.getProjectName(projectRoot);
|
||||
|
||||
for (const file of SWIFT_FILES) {
|
||||
const filepath = `${projectName}/${file}`;
|
||||
@@ -56,7 +96,9 @@ function withAppIntents(config) {
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
"path" in entry &&
|
||||
entry.path === file,
|
||||
(entry.path === file ||
|
||||
entry.path === `${projectName}/${file}` ||
|
||||
String(entry.path).endsWith(`/${file}`)),
|
||||
);
|
||||
|
||||
if (!alreadyLinked) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// @ts-check
|
||||
const { withXcodeProject } = require("@expo/config-plugins");
|
||||
|
||||
const RELEASE_SIGN_KEY = '"CODE_SIGN_IDENTITY[sdk=iphoneos*]"';
|
||||
|
||||
/**
|
||||
* RN / Expo sets Release CODE_SIGN_IDENTITY to "iPhone Developer", which forces
|
||||
* development-signed archives. Remove it so automatic signing picks Distribution
|
||||
* for App Store archives.
|
||||
*/
|
||||
/** @type {import('@expo/config-plugins').ConfigPlugin} */
|
||||
function withAppStoreSigning(config) {
|
||||
return withXcodeProject(config, (config) => {
|
||||
const project = config.modResults;
|
||||
const configurations = project.pbxXCBuildConfigurationSection();
|
||||
|
||||
for (const key of Object.keys(configurations)) {
|
||||
const buildConfig = configurations[key];
|
||||
if (!buildConfig || typeof buildConfig !== "object" || !buildConfig.buildSettings) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (buildConfig.name !== "Release") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const identity = buildConfig.buildSettings[RELEASE_SIGN_KEY];
|
||||
if (
|
||||
identity === "iPhone Developer" ||
|
||||
identity === '"iPhone Developer"' ||
|
||||
identity === "Apple Distribution" ||
|
||||
identity === '"Apple Distribution"'
|
||||
) {
|
||||
delete buildConfig.buildSettings[RELEASE_SIGN_KEY];
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withAppStoreSigning;
|
||||
Reference in New Issue
Block a user