@elizaos/capacitor-mobile-signals 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ import FamilyControls
2
+ import DeviceActivity
3
+ import Foundation
4
+ import Security
5
+
6
+ enum ScreenTimeSupport {
7
+ private static let familyControlsEntitlement = "com.apple.developer.family-controls"
8
+ private static let requiredFrameworks = ["FamilyControls", "DeviceActivity"]
9
+
10
+ private struct EntitlementInspection {
11
+ let familyControls: Bool
12
+ let inspected: String
13
+ let reason: String?
14
+
15
+ var satisfied: Bool {
16
+ familyControls
17
+ }
18
+
19
+ var canAttemptAuthorization: Bool {
20
+ inspected == "not-inspectable" || familyControls
21
+ }
22
+ }
23
+
24
+ static func buildStatus(reasonOverride: String? = nil) -> [String: Any] {
25
+ let entitlementInspection = inspectEntitlements()
26
+ let familyControlsEnabled = entitlementInspection.familyControls
27
+ let authorizationEntitlementAvailable = entitlementInspection.canAttemptAuthorization
28
+ let authorizationStatus = authorizationStatusString()
29
+ let provisioningSatisfied = entitlementInspection.satisfied
30
+
31
+ let reason = reasonOverride ?? derivedReason(
32
+ familyControlsEnabled: authorizationEntitlementAvailable,
33
+ authorizationStatus: authorizationStatus
34
+ )
35
+ let provisioningReason: Any = provisioningSatisfied
36
+ ? NSNull()
37
+ : (entitlementInspection.reason ?? reason)
38
+
39
+ return [
40
+ "supported": provisioningSatisfied || entitlementInspection.inspected == "not-inspectable",
41
+ "requirements": [
42
+ "entitlements": [
43
+ "familyControls": familyControlsEntitlement,
44
+ ],
45
+ "frameworks": requiredFrameworks,
46
+ "deviceActivityReportExtension": false,
47
+ "deviceActivityMonitorExtension": false,
48
+ ],
49
+ "entitlements": [
50
+ "familyControls": familyControlsEnabled,
51
+ ],
52
+ "provisioning": [
53
+ "satisfied": provisioningSatisfied,
54
+ "inspected": entitlementInspection.inspected,
55
+ "reason": provisioningReason,
56
+ ],
57
+ "authorization": [
58
+ "status": authorizationStatus,
59
+ "canRequest": canRequestAuthorization(
60
+ familyControlsEnabled: authorizationEntitlementAvailable,
61
+ authorizationStatus: authorizationStatus
62
+ ),
63
+ ],
64
+ "reportAvailable": false,
65
+ "coarseSummaryAvailable": false,
66
+ "thresholdEventsAvailable": false,
67
+ "rawUsageExportAvailable": false,
68
+ "reason": reason,
69
+ ]
70
+ }
71
+
72
+ static func requestAuthorizationIfAvailable(
73
+ completion: @escaping (String?) -> Void
74
+ ) {
75
+ let entitlementInspection = inspectEntitlements()
76
+ let authorizationStatus = authorizationStatusString()
77
+ guard canRequestAuthorization(
78
+ familyControlsEnabled: entitlementInspection.canAttemptAuthorization,
79
+ authorizationStatus: authorizationStatus
80
+ ) else {
81
+ DispatchQueue.main.async {
82
+ completion(nil)
83
+ }
84
+ return
85
+ }
86
+
87
+ if #available(iOS 16.0, *) {
88
+ Task { @MainActor in
89
+ do {
90
+ try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
91
+ completion(nil)
92
+ } catch {
93
+ completion("Screen Time authorization request failed: \(error.localizedDescription)")
94
+ }
95
+ }
96
+ return
97
+ }
98
+
99
+ DispatchQueue.main.async {
100
+ AuthorizationCenter.shared.requestAuthorization { result in
101
+ DispatchQueue.main.async {
102
+ switch result {
103
+ case .success:
104
+ completion(nil)
105
+ case .failure(let error):
106
+ completion("Screen Time authorization request failed: \(error.localizedDescription)")
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ private static func authorizationStatusString() -> String {
114
+ runOnMain {
115
+ switch AuthorizationCenter.shared.authorizationStatus {
116
+ case .approved:
117
+ return "approved"
118
+ case .denied:
119
+ return "denied"
120
+ case .notDetermined:
121
+ return "not-determined"
122
+ @unknown default:
123
+ return "unavailable"
124
+ }
125
+ }
126
+ }
127
+
128
+ private static func canRequestAuthorization(
129
+ familyControlsEnabled: Bool,
130
+ authorizationStatus: String
131
+ ) -> Bool {
132
+ familyControlsEnabled && authorizationStatus != "approved"
133
+ }
134
+
135
+ private static func derivedReason(
136
+ familyControlsEnabled: Bool,
137
+ authorizationStatus: String
138
+ ) -> String {
139
+ if !familyControlsEnabled {
140
+ return "Family Controls entitlement is missing from the app bundle."
141
+ }
142
+ if authorizationStatus == "not-determined" {
143
+ return "Screen Time authorization has not been granted yet."
144
+ }
145
+ if authorizationStatus == "denied" {
146
+ return "Screen Time authorization was denied on this device."
147
+ }
148
+ return "DeviceActivity report and monitor extensions are not wired in this checkout."
149
+ }
150
+
151
+ private static func inspectEntitlements() -> EntitlementInspection {
152
+ #if os(macOS)
153
+ return EntitlementInspection(
154
+ familyControls: entitlementIsEnabled(familyControlsEntitlement),
155
+ inspected: "code-signature",
156
+ reason: nil
157
+ )
158
+ #else
159
+ return EntitlementInspection(
160
+ familyControls: false,
161
+ inspected: "not-inspectable",
162
+ reason: "iOS entitlement inspection is handled by build validation and provisioning profile checks."
163
+ )
164
+ #endif
165
+ }
166
+
167
+ #if os(macOS)
168
+ private static func entitlementIsEnabled(_ key: String) -> Bool {
169
+ guard let task = SecTaskCreateFromSelf(nil) else {
170
+ return false
171
+ }
172
+ guard let value = SecTaskCopyValueForEntitlement(task, key as CFString, nil) else {
173
+ return false
174
+ }
175
+ if let boolean = value as? Bool {
176
+ return boolean
177
+ }
178
+ return false
179
+ }
180
+ #endif
181
+
182
+ private static func runOnMain<T>(_ work: () -> T) -> T {
183
+ if Thread.isMainThread {
184
+ return work()
185
+ }
186
+ return DispatchQueue.main.sync(execute: work)
187
+ }
188
+ }
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@elizaos/capacitor-mobile-signals",
3
+ "version": "1.0.0",
4
+ "description": "Bridges mobile wake, lock, battery, and protected-data state into LifeOps.",
5
+ "keywords": [
6
+ "mobile",
7
+ "wake",
8
+ "sleep",
9
+ "battery",
10
+ "biometric"
11
+ ],
12
+ "main": "./dist/plugin.cjs.js",
13
+ "module": "./dist/esm/index.js",
14
+ "types": "./dist/esm/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/esm/index.d.ts",
18
+ "import": "./dist/esm/index.js",
19
+ "require": "./dist/plugin.cjs.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "unpkg": "dist/plugin.js",
24
+ "files": [
25
+ "android/src/main/",
26
+ "android/build.gradle",
27
+ "dist/",
28
+ "ios/Sources/",
29
+ "scripts/validate-ios-screen-time.mjs",
30
+ "*.podspec"
31
+ ],
32
+ "author": "elizaOS",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/elizaOS/eliza"
37
+ },
38
+ "scripts": {
39
+ "build": "npm run clean && tsc && rollup -c rollup.config.mjs",
40
+ "clean": "rimraf ./dist",
41
+ "prepublishOnly": "npm run build",
42
+ "test": "vitest run",
43
+ "validate:ios-screen-time": "node scripts/validate-ios-screen-time.mjs",
44
+ "watch": "tsc --watch"
45
+ },
46
+ "devDependencies": {
47
+ "@capacitor/android": "^8.0.0",
48
+ "@capacitor/core": "^8.3.1",
49
+ "@capacitor/ios": "^8.0.0",
50
+ "@rollup/plugin-node-resolve": "^16.0.0",
51
+ "rimraf": "^6.0.0",
52
+ "rollup": "^4.60.2",
53
+ "typescript": "^6.0.0",
54
+ "vitest": "^4.0.17"
55
+ },
56
+ "peerDependencies": {
57
+ "@capacitor/core": "^8.3.1"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ },
62
+ "capacitor": {
63
+ "ios": {
64
+ "src": "ios",
65
+ "podName": "ElizaosCapacitorMobileSignals"
66
+ },
67
+ "android": {
68
+ "src": "android"
69
+ }
70
+ },
71
+ "elizaos": {
72
+ "platforms": [
73
+ "browser",
74
+ "node"
75
+ ],
76
+ "runtime": "both",
77
+ "platformDetails": {
78
+ "browser": "Graceful web fallback from document visibility, focus, and Battery Status APIs.",
79
+ "node": "No desktop native integration; mobile signals are captured on iOS and Android only.",
80
+ "ios": true,
81
+ "android": true
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const pluginRoot = path.resolve(__dirname, "..");
10
+ const defaultRepoRoot = path.resolve(pluginRoot, "..", "..", "..");
11
+
12
+ export const IOS_SCREEN_TIME_REQUIREMENTS = Object.freeze({
13
+ entitlements: Object.freeze({
14
+ familyControls: "com.apple.developer.family-controls",
15
+ }),
16
+ frameworks: Object.freeze(["FamilyControls", "DeviceActivity"]),
17
+ appEntitlementsRelativePath: path.join("App", "App.entitlements"),
18
+ });
19
+
20
+ export function defaultIosScreenTimeValidationPaths({
21
+ repoRootValue = defaultRepoRoot,
22
+ } = {}) {
23
+ return {
24
+ entitlementsPath: path.join(
25
+ repoRootValue,
26
+ "packages",
27
+ "app-core",
28
+ "platforms",
29
+ "ios",
30
+ "App",
31
+ "App",
32
+ "App.entitlements",
33
+ ),
34
+ projectPath: path.join(
35
+ repoRootValue,
36
+ "packages",
37
+ "app-core",
38
+ "platforms",
39
+ "ios",
40
+ "App",
41
+ "App.xcodeproj",
42
+ "project.pbxproj",
43
+ ),
44
+ podspecPath: path.join(
45
+ repoRootValue,
46
+ "packages",
47
+ "native-plugins",
48
+ "mobile-signals",
49
+ "ElizaosCapacitorMobileSignals.podspec",
50
+ ),
51
+ };
52
+ }
53
+
54
+ function escapeRegExp(value) {
55
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56
+ }
57
+
58
+ function addCheck(checks, id, ok, message, skipped = false) {
59
+ checks.push({ id, ok, skipped, message });
60
+ }
61
+
62
+ function readRequiredText(filePath, label) {
63
+ if (!filePath || !fs.existsSync(filePath)) {
64
+ throw new Error(`${label} does not exist at ${filePath ?? "(not set)"}`);
65
+ }
66
+ return fs.readFileSync(filePath, "utf8");
67
+ }
68
+
69
+ function findKeyEnd(plist, key) {
70
+ const pattern = new RegExp(`<key>\\s*${escapeRegExp(key)}\\s*</key>`, "m");
71
+ const match = pattern.exec(plist);
72
+ return match ? match.index + match[0].length : -1;
73
+ }
74
+
75
+ function extractNextDict(plist, startIndex) {
76
+ const dictStart = plist.indexOf("<dict>", startIndex);
77
+ if (dictStart === -1) return null;
78
+
79
+ const tokenPattern = /<\/?dict>/g;
80
+ tokenPattern.lastIndex = dictStart;
81
+ let depth = 0;
82
+ for (;;) {
83
+ const match = tokenPattern.exec(plist);
84
+ if (!match) return null;
85
+ depth += match[0] === "<dict>" ? 1 : -1;
86
+ if (depth === 0) {
87
+ return plist.slice(dictStart, tokenPattern.lastIndex);
88
+ }
89
+ }
90
+ }
91
+
92
+ function extractDictAfterKey(plist, key) {
93
+ const keyEnd = findKeyEnd(plist, key);
94
+ if (keyEnd === -1) return null;
95
+ return extractNextDict(plist, keyEnd);
96
+ }
97
+
98
+ function plistBooleanIsTrue(plist, key, { enclosingKey } = {}) {
99
+ const source = enclosingKey
100
+ ? extractDictAfterKey(plist, enclosingKey)
101
+ : plist;
102
+ if (!source) return false;
103
+ const pattern = new RegExp(
104
+ `<key>\\s*${escapeRegExp(key)}\\s*</key>\\s*<true\\s*/>`,
105
+ "m",
106
+ );
107
+ return pattern.test(source);
108
+ }
109
+
110
+ function missingRequiredEntitlements(plist, { enclosingKey } = {}) {
111
+ return Object.values(IOS_SCREEN_TIME_REQUIREMENTS.entitlements).filter(
112
+ (key) => !plistBooleanIsTrue(plist, key, { enclosingKey }),
113
+ );
114
+ }
115
+
116
+ function decodeProvisioningProfile(profilePath) {
117
+ const raw = fs.readFileSync(profilePath);
118
+ const text = raw.toString("utf8");
119
+ if (text.includes("<plist")) {
120
+ return text;
121
+ }
122
+
123
+ if (process.platform !== "darwin") {
124
+ throw new Error(
125
+ "binary provisioning profiles can only be decoded on macOS with the security tool",
126
+ );
127
+ }
128
+
129
+ const result = spawnSync("security", ["cms", "-D", "-i", profilePath], {
130
+ encoding: "utf8",
131
+ });
132
+ if (result.status !== 0) {
133
+ throw new Error(
134
+ result.stderr.trim() ||
135
+ `security cms failed with exit code ${result.status ?? 1}`,
136
+ );
137
+ }
138
+ return result.stdout;
139
+ }
140
+
141
+ export function validateIosScreenTimeBuildWiring(options = {}) {
142
+ const defaults = defaultIosScreenTimeValidationPaths(options);
143
+ const entitlementsPath =
144
+ options.entitlementsPath ?? defaults.entitlementsPath;
145
+ const projectPath = options.projectPath ?? defaults.projectPath;
146
+ const podspecPath = options.podspecPath ?? defaults.podspecPath;
147
+ const provisioningProfilePath =
148
+ options.provisioningProfilePath ??
149
+ process.env.MOBILE_SIGNALS_IOS_PROVISIONING_PROFILE;
150
+ const requireProvisioningProfile =
151
+ options.requireProvisioningProfile ??
152
+ process.env.MOBILE_SIGNALS_REQUIRE_IOS_PROVISIONING_PROFILE === "1";
153
+ const checks = [];
154
+
155
+ try {
156
+ const entitlements = readRequiredText(entitlementsPath, "iOS entitlements");
157
+ const missing = missingRequiredEntitlements(entitlements);
158
+ addCheck(
159
+ checks,
160
+ "app-entitlements",
161
+ missing.length === 0,
162
+ missing.length === 0
163
+ ? `App entitlements include ${Object.values(
164
+ IOS_SCREEN_TIME_REQUIREMENTS.entitlements,
165
+ ).join(", ")}.`
166
+ : `App entitlements are missing required Screen Time keys: ${missing.join(
167
+ ", ",
168
+ )}.`,
169
+ );
170
+ } catch (error) {
171
+ addCheck(checks, "app-entitlements", false, error.message);
172
+ }
173
+
174
+ try {
175
+ const project = readRequiredText(projectPath, "Xcode project");
176
+ const expected = `CODE_SIGN_ENTITLEMENTS = ${IOS_SCREEN_TIME_REQUIREMENTS.appEntitlementsRelativePath};`;
177
+ addCheck(
178
+ checks,
179
+ "xcode-entitlements-build-setting",
180
+ project.includes(expected),
181
+ project.includes(expected)
182
+ ? "Xcode project signs the app target with App/App.entitlements."
183
+ : `Xcode project does not contain ${expected}`,
184
+ );
185
+ } catch (error) {
186
+ addCheck(checks, "xcode-entitlements-build-setting", false, error.message);
187
+ }
188
+
189
+ try {
190
+ const podspec = readRequiredText(podspecPath, "mobile-signals podspec");
191
+ const missingFrameworks = IOS_SCREEN_TIME_REQUIREMENTS.frameworks.filter(
192
+ (framework) => !podspec.includes(framework),
193
+ );
194
+ addCheck(
195
+ checks,
196
+ "podspec-frameworks",
197
+ missingFrameworks.length === 0,
198
+ missingFrameworks.length === 0
199
+ ? "mobile-signals podspec links FamilyControls and DeviceActivity."
200
+ : `mobile-signals podspec is missing frameworks: ${missingFrameworks.join(
201
+ ", ",
202
+ )}.`,
203
+ );
204
+ } catch (error) {
205
+ addCheck(checks, "podspec-frameworks", false, error.message);
206
+ }
207
+
208
+ if (provisioningProfilePath) {
209
+ try {
210
+ if (!fs.existsSync(provisioningProfilePath)) {
211
+ throw new Error(
212
+ `Provisioning profile does not exist at ${provisioningProfilePath}`,
213
+ );
214
+ }
215
+ const profilePlist = decodeProvisioningProfile(provisioningProfilePath);
216
+ const missing = missingRequiredEntitlements(profilePlist, {
217
+ enclosingKey: "Entitlements",
218
+ });
219
+ addCheck(
220
+ checks,
221
+ "provisioning-entitlements",
222
+ missing.length === 0,
223
+ missing.length === 0
224
+ ? "Provisioning profile includes required Screen Time entitlements."
225
+ : `Provisioning profile is missing required Screen Time keys: ${missing.join(
226
+ ", ",
227
+ )}.`,
228
+ );
229
+ } catch (error) {
230
+ addCheck(checks, "provisioning-entitlements", false, error.message);
231
+ }
232
+ } else if (requireProvisioningProfile) {
233
+ addCheck(
234
+ checks,
235
+ "provisioning-entitlements",
236
+ false,
237
+ "MOBILE_SIGNALS_REQUIRE_IOS_PROVISIONING_PROFILE=1 but no provisioning profile was supplied.",
238
+ );
239
+ } else {
240
+ addCheck(
241
+ checks,
242
+ "provisioning-entitlements",
243
+ true,
244
+ "No provisioning profile supplied; skipping profile entitlement inspection.",
245
+ true,
246
+ );
247
+ }
248
+
249
+ const failures = checks.filter((check) => !check.ok);
250
+ return {
251
+ ok: failures.length === 0,
252
+ checks,
253
+ failures,
254
+ requirements: IOS_SCREEN_TIME_REQUIREMENTS,
255
+ };
256
+ }
257
+
258
+ export function assertIosScreenTimeBuildWiring(options = {}) {
259
+ const result = validateIosScreenTimeBuildWiring(options);
260
+ if (!result.ok) {
261
+ throw new Error(
262
+ [
263
+ "iOS Screen Time build wiring is invalid:",
264
+ ...result.failures.map((failure) => `- ${failure.message}`),
265
+ ].join("\n"),
266
+ );
267
+ }
268
+ return result;
269
+ }
270
+
271
+ function parseArgs(argv) {
272
+ const options = {};
273
+ for (let index = 0; index < argv.length; index += 1) {
274
+ const arg = argv[index];
275
+ const next = () => argv[++index];
276
+ if (arg === "--json") {
277
+ options.json = true;
278
+ } else if (arg === "--repo-root") {
279
+ options.repoRootValue = path.resolve(next());
280
+ } else if (arg === "--entitlements") {
281
+ options.entitlementsPath = path.resolve(next());
282
+ } else if (arg === "--project") {
283
+ options.projectPath = path.resolve(next());
284
+ } else if (arg === "--podspec") {
285
+ options.podspecPath = path.resolve(next());
286
+ } else if (arg === "--provisioning-profile") {
287
+ options.provisioningProfilePath = path.resolve(next());
288
+ } else if (arg === "--require-provisioning-profile") {
289
+ options.requireProvisioningProfile = true;
290
+ } else {
291
+ throw new Error(`Unknown argument: ${arg}`);
292
+ }
293
+ }
294
+ return options;
295
+ }
296
+
297
+ const isMain =
298
+ process.argv[1] &&
299
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
300
+
301
+ if (isMain) {
302
+ try {
303
+ const options = parseArgs(process.argv.slice(2));
304
+ const result = validateIosScreenTimeBuildWiring(options);
305
+ if (options.json) {
306
+ console.log(JSON.stringify(result, null, 2));
307
+ } else {
308
+ for (const check of result.checks) {
309
+ const prefix = check.skipped ? "SKIP" : check.ok ? "OK" : "FAIL";
310
+ console.log(`[${prefix}] ${check.message}`);
311
+ }
312
+ }
313
+ if (!result.ok) {
314
+ process.exitCode = 1;
315
+ }
316
+ } catch (error) {
317
+ console.error(error instanceof Error ? error.message : String(error));
318
+ process.exitCode = 1;
319
+ }
320
+ }