Files
openwhispr/scripts/lib/build-macos-swift-binary.js
Gabriel SteinandChadpiha 5d69b75a2e feat(calendar): Apple Calendar (EventKit) integration for macOS (#1237)
* feat(calendar): Apple Calendar (EventKit) integration for macOS

Add a second calendar provider that reads the local EventKit store —
covering iCloud, Google, Exchange, and Outlook accounts already in
Calendar.app — with one native permission prompt and push-based updates
instead of OAuth + polling.

- New macos-calendar-listener Swift helper (mic-listener pattern):
  emits permission + calendars/events snapshots as line-delimited JSON,
  re-emits on EKEventStoreChanged, stdin "sync", and a 5-min window roll;
  --request triggers the TCC prompt attributed to OpenWhispr
- Build script patches dev Electron's Info.plist with calendar usage
  strings; packaged builds get them via electron-builder extendInfo,
  plus the calendars entitlement for hardened runtime
- calendar_events gains a provider column; new apple_calendars table;
  Google deletes are provider-scoped; shared read queries suppress
  Apple copies of Google-mirrored meetings (datetime-normalized, Google
  rows never collapsed) so reminders fire once per meeting
- Meeting reminder scheduling extracted from GoogleCalendarManager into
  a provider-agnostic CalendarReminderScheduler shared by both managers
- AppleCalendarManager owns the helper lifecycle; meeting join links are
  extracted from url/location/notes into hangout_link at sync time so
  Join buttons and note linking work unchanged
- Integrations UI gets an Apple Calendar row (macOS only) with connect,
  denied → System Settings flow, source list, and disconnect; i18n keys
  added to all 10 locales

Downstream consumers (Upcoming Meetings, overlay reminders, agent
calendar tool, note linking) read the shared table and work for both
providers with no changes.

* refactor(calendar): Harden EventKit integration and extract shared helpers

* fix(calendar): Show the calendar permission prompt in dev builds

* chore(calendar): Clean up after dev TCC fix

* fix: tentative meeting responses and stale state fix

* fix: dev script

* fix(tests): broken test after rebase

---------

Co-authored-by: Chadpiha <chadpiha23@gmail.com>
2026-08-03 10:42:31 -07:00

202 lines
6.1 KiB
JavaScript

const { spawnSync } = require("child_process");
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const ARCH_TO_TARGET = {
arm64: "arm64-apple-macosx11.0",
x64: "x86_64-apple-macosx10.15",
};
// Mach-O CPU type constants for architecture verification
const ARCH_CPU_TYPE = {
arm64: 0x0100000c, // CPU_TYPE_ARM64
x64: 0x01000007, // CPU_TYPE_X86_64
};
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function verifyBinaryArch(binaryPath, expectedArch) {
try {
const fd = fs.openSync(binaryPath, "r");
const header = Buffer.alloc(8);
fs.readSync(fd, header, 0, 8, 0);
fs.closeSync(fd);
const magic = header.readUInt32LE(0);
if (magic !== 0xfeedfacf) {
// Not a 64-bit Mach-O
return false;
}
const cpuType = header.readInt32LE(4);
const expectedCpu = ARCH_CPU_TYPE[expectedArch];
return cpuType === expectedCpu;
} catch {
return false;
}
}
// Shared build flow for the macOS Swift helper binaries (mic listener, globe
// listener, text monitor, calendar listener). Compiles resources/<sourceName>
// to resources/bin/<binaryName> for the target arch, skipping the build when
// the binary is up to date (mtime + source hash + arch check). Exits the
// process on failure, matching the original standalone scripts.
// linkerInfoPlist embeds the given plist as a __TEXT,__info_plist section so
// unbundled binaries can carry a bundle id and privacy usage strings.
function buildMacosSwiftBinary({ label, sourceName, binaryName, frameworks = [], linkerInfoPlist }) {
if (process.platform !== "darwin") {
process.exit(0);
}
// Support cross-compilation via --arch flag or TARGET_ARCH env var
const archIndex = process.argv.indexOf("--arch");
const targetArch =
(archIndex !== -1 && process.argv[archIndex + 1]) || process.env.TARGET_ARCH || process.arch;
const swiftTarget = ARCH_TO_TARGET[targetArch];
if (!swiftTarget) {
console.error(`[${label}] Unsupported architecture: ${targetArch}`);
process.exit(1);
}
const projectRoot = path.resolve(__dirname, "..", "..");
const swiftSource = path.join(projectRoot, "resources", sourceName);
const outputDir = path.join(projectRoot, "resources", "bin");
const outputBinary = path.join(outputDir, binaryName);
const hashFile = path.join(outputDir, `.${binaryName}.${targetArch}.hash`);
const moduleCacheDir = path.join(outputDir, ".swift-module-cache");
function log(message) {
console.log(`[${label}] ${message}`);
}
if (!fs.existsSync(swiftSource)) {
console.error(`[${label}] Swift source not found at ${swiftSource}`);
process.exit(1);
}
ensureDir(outputDir);
ensureDir(moduleCacheDir);
let needsBuild = true;
if (fs.existsSync(outputBinary)) {
// Verify existing binary matches the target architecture
if (!verifyBinaryArch(outputBinary, targetArch)) {
log(`Existing binary is wrong architecture (expected ${targetArch}), rebuild needed`);
needsBuild = true;
} else {
try {
const binaryStat = fs.statSync(outputBinary);
const sourceStat = fs.statSync(swiftSource);
if (binaryStat.mtimeMs >= sourceStat.mtimeMs) {
needsBuild = false;
}
} catch {
needsBuild = true;
}
}
}
// Hash the Swift source plus any embedded plist, so edits to either rebuild
function computeSourceHash() {
let content = fs.readFileSync(swiftSource, "utf8");
if (linkerInfoPlist) content += fs.readFileSync(linkerInfoPlist, "utf8");
return crypto.createHash("sha256").update(content).digest("hex");
}
// Secondary check: compare source hash
if (!needsBuild && fs.existsSync(outputBinary)) {
try {
const currentHash = computeSourceHash();
if (fs.existsSync(hashFile)) {
const savedHash = fs.readFileSync(hashFile, "utf8").trim();
if (savedHash !== currentHash) {
log("Source hash changed, rebuild needed");
needsBuild = true;
}
} else {
// No hash file for this architecture — force rebuild to ensure correct arch
log(`No hash file for ${targetArch}, rebuild needed`);
needsBuild = true;
}
} catch (err) {
log(`Hash check failed: ${err.message}, forcing rebuild`);
needsBuild = true;
}
}
if (!needsBuild) {
process.exit(0);
}
function attemptCompile(command, args) {
log(`Compiling with ${[command, ...args].join(" ")}`);
return spawnSync(command, args, {
stdio: "inherit",
env: {
...process.env,
SWIFT_MODULE_CACHE_PATH: moduleCacheDir,
},
});
}
const infoPlistArgs = linkerInfoPlist
? ["-Xlinker", "-sectcreate", "-Xlinker", "__TEXT", "-Xlinker", "__info_plist", "-Xlinker", linkerInfoPlist]
: [];
const compileArgs = [
swiftSource,
"-O",
"-target",
swiftTarget,
"-module-cache-path",
moduleCacheDir,
"-o",
outputBinary,
...infoPlistArgs,
...frameworks.flatMap((framework) => ["-framework", framework]),
];
let result = attemptCompile("xcrun", ["swiftc", ...compileArgs]);
if (result.status !== 0) {
result = attemptCompile("swiftc", compileArgs);
}
if (result.status !== 0) {
console.error(`[${label}] Failed to compile ${binaryName} binary.`);
process.exit(result.status ?? 1);
}
try {
fs.chmodSync(outputBinary, 0o755);
} catch (error) {
console.warn(`[${label}] Unable to set executable permissions: ${error.message}`);
}
// Verify the compiled binary matches the target architecture
if (!verifyBinaryArch(outputBinary, targetArch)) {
console.error(
`[${label}] FATAL: Compiled binary architecture does not match target (${targetArch}). ` +
`This can happen when cross-compiling without setting TARGET_ARCH env var.`
);
process.exit(1);
}
// Save source hash after successful build
try {
fs.writeFileSync(hashFile, computeSourceHash());
} catch (err) {
// Non-critical, just log
log(`Warning: Could not save source hash: ${err.message}`);
}
log(`Successfully built ${binaryName} (${targetArch}).`);
}
module.exports = { buildMacosSwiftBinary };