@capgo/capacitor-video-player 8.2.7 → 8.2.9

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.
@@ -46,7 +46,7 @@ import org.json.JSONObject;
46
46
  )
47
47
  public class VideoPlayerPlugin extends Plugin {
48
48
 
49
- private final String pluginVersion = "8.2.7";
49
+ private final String pluginVersion = "8.2.9";
50
50
 
51
51
  // Permission alias constants
52
52
  private static final String PERMISSION_DENIED_ERROR = "Unable to access media videos, user denied permission request";
@@ -263,7 +263,7 @@ public class VideoPlayerPlugin extends Plugin {
263
263
  if (isPermissionsGranted()) {
264
264
  _initPlayer(call);
265
265
  } else {
266
- this.bridge.saveCall(call);
266
+ call.setKeepAlive(true);
267
267
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
268
268
  requestPermissionForAlias(MEDIAVIDEO, call, "permissionsCallback");
269
269
  } else {
@@ -5,15 +5,38 @@ final class DismissObservingPlayerViewController: AVPlayerViewController {
5
5
  var onDismiss: (() -> Void)?
6
6
  var shouldReportDismiss: (() -> Bool)?
7
7
  weak var adaptivePresentationDelegate: UIAdaptivePresentationControllerDelegate?
8
+ private var wasVisibleInHierarchy = false
8
9
 
9
10
  override func viewWillAppear(_ animated: Bool) {
10
11
  super.viewWillAppear(animated)
11
12
  presentationController?.delegate = adaptivePresentationDelegate
12
13
  }
13
14
 
15
+ override func viewDidAppear(_ animated: Bool) {
16
+ super.viewDidAppear(animated)
17
+ wasVisibleInHierarchy = true
18
+ }
19
+
14
20
  override func viewDidDisappear(_ animated: Bool) {
15
21
  super.viewDidDisappear(animated)
16
- guard isBeingDismissed else { return }
22
+ reportDismissIfNeeded()
23
+ }
24
+
25
+ override func dismiss(animated: Bool, completion: (() -> Void)? = nil) {
26
+ super.dismiss(animated: animated) { [weak self] in
27
+ self?.reportDismissIfNeeded()
28
+ completion?()
29
+ }
30
+ }
31
+
32
+ private func reportDismissIfNeeded() {
33
+ guard wasVisibleInHierarchy else { return }
34
+ guard PlayerDismissExitDetection.shouldReportExit(
35
+ presentingViewController: presentingViewController,
36
+ viewWindow: view.window
37
+ ) else {
38
+ return
39
+ }
17
40
  guard shouldReportDismiss?() ?? true else { return }
18
41
  onDismiss?()
19
42
  }
@@ -0,0 +1,16 @@
1
+ import UIKit
2
+
3
+ enum PlayerDismissExitDetection {
4
+ /// Returns true when a player view controller has left the visible hierarchy
5
+ /// because it was dismissed, not merely covered by another view controller.
6
+ static func shouldReportExit(
7
+ presentingViewController: UIViewController?,
8
+ viewWindow: UIWindow?
9
+ ) -> Bool {
10
+ // iOS 18+/26: AVKit Done/X can dismiss a modal AVPlayerViewController without
11
+ // setting isBeingDismissed or calling presentationControllerDidDismiss.
12
+ // Require both presenter and window to be nil so parent dismissals that only
13
+ // transiently set isBeingDismissed/isMovingFromParent do not emit exit.
14
+ presentingViewController == nil && viewWindow == nil
15
+ }
16
+ }
@@ -8,7 +8,7 @@ import AVKit
8
8
  */
9
9
  @objc(VideoPlayerPlugin)
10
10
  public class VideoPlayerPlugin: CAPPlugin, CAPBridgedPlugin {
11
- private let pluginVersion: String = "8.2.7"
11
+ private let pluginVersion: String = "8.2.9"
12
12
  public let identifier = "VideoPlayerPlugin"
13
13
  public let jsName = "VideoPlayer"
14
14
  public let pluginMethods: [CAPPluginMethod] = [
@@ -0,0 +1,49 @@
1
+ import XCTest
2
+ @testable import VideoPlayerPlugin
3
+
4
+ final class PlayerDismissExitDetectionTests: XCTestCase {
5
+ func testReportsExitWhenModalHierarchyIsGone() {
6
+ XCTAssertTrue(
7
+ PlayerDismissExitDetection.shouldReportExit(
8
+ presentingViewController: nil,
9
+ viewWindow: nil
10
+ )
11
+ )
12
+ }
13
+
14
+ func testDoesNotReportExitWhenBeingDismissedButStillInHierarchy() {
15
+ XCTAssertFalse(
16
+ PlayerDismissExitDetection.shouldReportExit(
17
+ presentingViewController: UIViewController(),
18
+ viewWindow: UIWindow()
19
+ )
20
+ )
21
+ }
22
+
23
+ func testDoesNotReportExitWhenCoveredByAnotherViewController() {
24
+ XCTAssertFalse(
25
+ PlayerDismissExitDetection.shouldReportExit(
26
+ presentingViewController: UIViewController(),
27
+ viewWindow: UIWindow()
28
+ )
29
+ )
30
+ }
31
+
32
+ func testDoesNotReportExitWhenOnlyPresenterIsNil() {
33
+ XCTAssertFalse(
34
+ PlayerDismissExitDetection.shouldReportExit(
35
+ presentingViewController: nil,
36
+ viewWindow: UIWindow()
37
+ )
38
+ )
39
+ }
40
+
41
+ func testDoesNotReportExitWhenOnlyWindowIsNil() {
42
+ XCTAssertFalse(
43
+ PlayerDismissExitDetection.shouldReportExit(
44
+ presentingViewController: UIViewController(),
45
+ viewWindow: nil
46
+ )
47
+ )
48
+ }
49
+ }
@@ -2,13 +2,6 @@ import XCTest
2
2
  @testable import VideoPlayerPlugin
3
3
 
4
4
  class VideoPlayerTests: XCTestCase {
5
- func testEcho() {
6
- let implementation = VideoPlayer()
7
- let value = "Hello, World!"
8
- let result = implementation.echo(value)
9
- XCTAssertEqual(value, result)
10
- }
11
-
12
5
  func testSubtitleTrackParserPrefersSubtitlesArray() {
13
6
  let tracks = VideoSubtitleTrackParser.parse(
14
7
  from: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-video-player",
3
- "version": "8.2.7",
3
+ "version": "8.2.9",
4
4
  "description": "Capacitor plugin to play video in native player",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",
@@ -12,6 +12,7 @@
12
12
  "dist/",
13
13
  "ios/Sources",
14
14
  "ios/Tests",
15
+ "scripts/",
15
16
  "Package.swift",
16
17
  "CapgoCapacitorVideoPlayer.podspec"
17
18
  ],
@@ -33,8 +34,9 @@
33
34
  "player"
34
35
  ],
35
36
  "scripts": {
36
- "verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
37
+ "verify": "npm run verify:ios && npm run test:ios && npm run verify:android && npm run verify:web",
37
38
  "verify:ios": "xcodebuild -scheme CapgoCapacitorVideoPlayer -destination generic/platform=iOS",
39
+ "test:ios": "./scripts/test-ios.sh",
38
40
  "verify:android": "cd android && ./gradlew clean build test && cd ..",
39
41
  "verify:web": "npm run build",
40
42
  "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
@@ -48,6 +50,7 @@
48
50
  "watch": "tsc --watch",
49
51
  "prepublishOnly": "bun run build",
50
52
  "check:wiring": "node scripts/check-capacitor-plugin-wiring.mjs",
53
+ "check:cap9-deprecated": "node scripts/check-cap9-deprecated.mjs",
51
54
  "example:install": "cd example-app && bun install --frozen-lockfile",
52
55
  "example:build": "bun run build && cd example-app && bun install --frozen-lockfile && bun run build",
53
56
  "changelog:ai": "node scripts/generate-ai-changelog.mjs"
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Capacitor 9 deprecated native API guard.
4
+ *
5
+ * Fails when plugin native sources still use APIs removed in Capacitor 9.
6
+ * Does not flag Cordova SwiftPM product dependencies (still required on Cap 8).
7
+ *
8
+ * Usage:
9
+ * node scripts/check-cap9-deprecated.mjs
10
+ * node scripts/check-cap9-deprecated.mjs --workspace
11
+ * node scripts/check-cap9-deprecated.mjs --dir path
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const SKIP_DIRS = new Set([
19
+ "node_modules",
20
+ "dist",
21
+ "build",
22
+ ".build",
23
+ ".gradle",
24
+ "Pods",
25
+ "DerivedData",
26
+ ".swiftpm",
27
+ ".git",
28
+ "example-app",
29
+ ]);
30
+
31
+ /** @type {{ id: string, pattern: RegExp, exts: string[], ignoreLine?: RegExp }[]} */
32
+ const RULES = [
33
+ {
34
+ id: "hasOption",
35
+ pattern: /\bhasOption\s*\(/,
36
+ exts: [".java", ".kt", ".swift"],
37
+ },
38
+ {
39
+ id: "getConfigValue",
40
+ pattern: /\bgetConfigValue\s*\(/,
41
+ exts: [".java", ".kt", ".swift"],
42
+ },
43
+ {
44
+ id: "@NativePlugin",
45
+ pattern: /@NativePlugin\b/,
46
+ exts: [".java", ".kt"],
47
+ },
48
+ {
49
+ id: "saveCall",
50
+ pattern: /\bsaveCall\s*\(/,
51
+ exts: [".java", ".kt", ".swift"],
52
+ },
53
+ {
54
+ id: "getSavedCall",
55
+ pattern: /\bgetSavedCall\s*\(/,
56
+ exts: [".java", ".kt", ".swift"],
57
+ },
58
+ {
59
+ id: "freeSavedCall",
60
+ pattern: /\bfreeSavedCall\s*\(/,
61
+ exts: [".java", ".kt", ".swift"],
62
+ },
63
+ {
64
+ id: "releaseCall",
65
+ pattern: /\breleaseCall\s*\(/,
66
+ exts: [".java", ".kt", ".swift"],
67
+ ignoreLine: /\.releaseCall\s*\(\s*withID:/,
68
+ },
69
+ {
70
+ id: "pluginRequestPermission",
71
+ pattern: /\bpluginRequestPermissions?\s*\(/,
72
+ exts: [".java", ".kt"],
73
+ },
74
+ {
75
+ id: "pluginRequestAllPermissions",
76
+ pattern: /\bpluginRequestAllPermissions\s*\(/,
77
+ exts: [".java", ".kt"],
78
+ },
79
+ {
80
+ id: "hasDefinedPermissions",
81
+ pattern: /\bhasDefinedPermissions\s*\(/,
82
+ exts: [".java", ".kt"],
83
+ },
84
+ {
85
+ id: "CAPBridge",
86
+ pattern: /\bCAPBridge\./,
87
+ exts: [".swift"],
88
+ ignoreLine: /CAPBridgedPlugin/,
89
+ },
90
+ {
91
+ id: "CAPNotifications",
92
+ pattern: /\bCAPNotifications\b/,
93
+ exts: [".swift"],
94
+ },
95
+ ];
96
+
97
+ const CORDova_SPM_LINE =
98
+ /\.product\s*\(\s*name\s*:\s*"Cordova"\s*,\s*package\s*:\s*"capacitor-swift-pm"\s*\)/;
99
+
100
+ function readText(p) {
101
+ try {
102
+ return fs.readFileSync(p, "utf8");
103
+ } catch {
104
+ return "";
105
+ }
106
+ }
107
+
108
+ function exists(p) {
109
+ try {
110
+ fs.accessSync(p);
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ function parseArgs(argv) {
118
+ const out = { dir: process.cwd(), workspace: false };
119
+ for (let i = 2; i < argv.length; i++) {
120
+ const a = argv[i];
121
+ if (a === "--workspace") {
122
+ out.workspace = true;
123
+ continue;
124
+ }
125
+ if (a === "--dir" || a === "--pluginDir") {
126
+ out.dir = path.resolve(argv[++i] || ".");
127
+ continue;
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+
133
+ function walkFiles(rootDir, exts) {
134
+ const out = [];
135
+ const stack = [rootDir];
136
+ while (stack.length) {
137
+ const dir = stack.pop();
138
+ let entries;
139
+ try {
140
+ entries = fs.readdirSync(dir, { withFileTypes: true });
141
+ } catch {
142
+ continue;
143
+ }
144
+ for (const e of entries) {
145
+ if (e.isDirectory()) {
146
+ if (SKIP_DIRS.has(e.name)) continue;
147
+ stack.push(path.join(dir, e.name));
148
+ continue;
149
+ }
150
+ if (!e.isFile()) continue;
151
+ for (const ext of exts) {
152
+ if (e.name.endsWith(ext)) {
153
+ out.push(path.join(dir, e.name));
154
+ break;
155
+ }
156
+ }
157
+ }
158
+ }
159
+ out.sort();
160
+ return out;
161
+ }
162
+
163
+ function collectScanRoots(pluginDir, cap) {
164
+ const roots = [];
165
+ if (cap.android) {
166
+ const androidMain = path.join(pluginDir, "android", "src", "main");
167
+ if (exists(androidMain)) roots.push(androidMain);
168
+ }
169
+ if (cap.ios) {
170
+ const iosSources = path.join(pluginDir, "ios", "Sources");
171
+ if (exists(iosSources)) roots.push(iosSources);
172
+ else {
173
+ const iosDir = path.join(pluginDir, "ios");
174
+ if (exists(iosDir)) roots.push(iosDir);
175
+ }
176
+ }
177
+ const packageSwift = path.join(pluginDir, "Package.swift");
178
+ if (exists(packageSwift)) roots.push(packageSwift);
179
+ return roots;
180
+ }
181
+
182
+ function scanFile(filePath, rule) {
183
+ const ext = path.extname(filePath);
184
+ if (!rule.exts.includes(ext)) return [];
185
+
186
+ const txt = readText(filePath);
187
+ const lines = txt.split(/\r?\n/);
188
+ const hits = [];
189
+ for (let i = 0; i < lines.length; i++) {
190
+ const line = lines[i];
191
+ if (filePath.endsWith("Package.swift") && CORDova_SPM_LINE.test(line)) {
192
+ continue;
193
+ }
194
+ if (rule.ignoreLine?.test(line)) continue;
195
+ if (rule.pattern.test(line)) {
196
+ hits.push({ line: i + 1, text: line.trim() });
197
+ }
198
+ }
199
+ return hits;
200
+ }
201
+
202
+ function listWorkspacePluginDirs(repoRoot) {
203
+ const packagesRoot = path.join(repoRoot, "packages");
204
+ if (!exists(packagesRoot)) return [];
205
+ return fs
206
+ .readdirSync(packagesRoot, { withFileTypes: true })
207
+ .filter((e) => e.isDirectory())
208
+ .map((e) => path.join(packagesRoot, e.name))
209
+ .filter((dir) => {
210
+ const pkgPath = path.join(dir, "package.json");
211
+ if (!exists(pkgPath)) return false;
212
+ try {
213
+ const pkg = JSON.parse(readText(pkgPath));
214
+ const cap = typeof pkg.capacitor === "object" && pkg.capacitor ? pkg.capacitor : {};
215
+ return Boolean(cap.android || cap.ios);
216
+ } catch {
217
+ return false;
218
+ }
219
+ })
220
+ .sort();
221
+ }
222
+
223
+ /**
224
+ * @returns {boolean} true when the plugin passes
225
+ */
226
+ function checkPluginDir(pluginDir) {
227
+ const pkgPath = path.join(pluginDir, "package.json");
228
+
229
+ if (!exists(pkgPath)) {
230
+ console.error(`[cap9-deprecated] ERROR: missing package.json in ${pluginDir}`);
231
+ return false;
232
+ }
233
+
234
+ let pkg;
235
+ try {
236
+ pkg = JSON.parse(readText(pkgPath));
237
+ } catch (e) {
238
+ console.error(`[cap9-deprecated] ERROR: invalid package.json (${pkgPath}): ${e?.message || e}`);
239
+ return false;
240
+ }
241
+
242
+ const cap = typeof pkg.capacitor === "object" && pkg.capacitor ? pkg.capacitor : {};
243
+ if (!cap.android && !cap.ios) {
244
+ return true;
245
+ }
246
+
247
+ const scanRoots = collectScanRoots(pluginDir, cap);
248
+ const allExts = [...new Set(RULES.flatMap((r) => r.exts))];
249
+ const files = [];
250
+ for (const root of scanRoots) {
251
+ if (root.endsWith("Package.swift")) {
252
+ files.push(root);
253
+ continue;
254
+ }
255
+ files.push(...walkFiles(root, allExts));
256
+ }
257
+
258
+ const violations = [];
259
+ for (const file of files) {
260
+ for (const rule of RULES) {
261
+ const hits = scanFile(file, rule);
262
+ for (const hit of hits) {
263
+ violations.push({
264
+ rule: rule.id,
265
+ file: path.relative(pluginDir, file),
266
+ line: hit.line,
267
+ text: hit.text,
268
+ });
269
+ }
270
+ }
271
+ }
272
+
273
+ if (violations.length) {
274
+ const relDir = path.relative(process.cwd(), pluginDir) || ".";
275
+ console.error(`[cap9-deprecated] FAIL in ${relDir}`);
276
+ for (const v of violations) {
277
+ console.error(`- ${v.rule}: ${v.file}:${v.line}: ${v.text}`);
278
+ }
279
+ return false;
280
+ }
281
+
282
+ return true;
283
+ }
284
+
285
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
286
+ const args = parseArgs(process.argv);
287
+
288
+ if (args.workspace) {
289
+ const pluginDirs = listWorkspacePluginDirs(repoRoot);
290
+ if (!pluginDirs.length) {
291
+ console.error("[cap9-deprecated] ERROR: no Capacitor plugin packages found under packages/*");
292
+ process.exit(2);
293
+ }
294
+ let failed = false;
295
+ for (const dir of pluginDirs) {
296
+ if (!checkPluginDir(dir)) failed = true;
297
+ }
298
+ if (failed) process.exit(1);
299
+ console.log(`[cap9-deprecated] OK (${pluginDirs.length} plugin package(s) scanned)`);
300
+ process.exit(0);
301
+ }
302
+
303
+ const ok = checkPluginDir(args.dir);
304
+ process.exit(ok ? 0 : 1);
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Capacitor plugin wiring/name checker.
4
+ *
5
+ * Enforces that runtime plugin name matches across:
6
+ * - JS: registerPlugin('Name')
7
+ * - Android: @CapacitorPlugin(name = "Name")
8
+ * - iOS: CAPBridgedPlugin jsName = "Name"
9
+ *
10
+ * And (when iOS is declared in package.json capacitor config):
11
+ * - CocoaPods podspec s.name matches SwiftPM Package(name: ...) and .library(name: ...)
12
+ *
13
+ * Usage:
14
+ * node scripts/check-capacitor-plugin-wiring.mjs # checks current working dir
15
+ * node scripts/check-capacitor-plugin-wiring.mjs --dir path # checks given plugin dir
16
+ */
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+
21
+ const SKIP_DIRS = new Set([
22
+ "node_modules",
23
+ "dist",
24
+ "build",
25
+ ".build",
26
+ ".gradle",
27
+ "Pods",
28
+ "DerivedData",
29
+ ".swiftpm",
30
+ ".git",
31
+ ]);
32
+
33
+ function readText(p) {
34
+ try {
35
+ return fs.readFileSync(p, "utf8");
36
+ } catch {
37
+ return "";
38
+ }
39
+ }
40
+
41
+ function exists(p) {
42
+ try {
43
+ fs.accessSync(p);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ function walkFiles(rootDir, exts) {
51
+ const out = [];
52
+ const stack = [rootDir];
53
+ while (stack.length) {
54
+ const dir = stack.pop();
55
+ let entries;
56
+ try {
57
+ entries = fs.readdirSync(dir, { withFileTypes: true });
58
+ } catch {
59
+ continue;
60
+ }
61
+ for (const e of entries) {
62
+ if (e.isDirectory()) {
63
+ if (SKIP_DIRS.has(e.name)) continue;
64
+ stack.push(path.join(dir, e.name));
65
+ continue;
66
+ }
67
+ if (!e.isFile()) continue;
68
+ for (const ext of exts) {
69
+ if (e.name.endsWith(ext)) {
70
+ out.push(path.join(dir, e.name));
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ }
76
+ out.sort();
77
+ return out;
78
+ }
79
+
80
+ function uniq(arr) {
81
+ const out = [];
82
+ for (const x of arr) {
83
+ if (!x) continue;
84
+ if (!out.includes(x)) out.push(x);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ function parseArgs(argv) {
90
+ const out = { dir: process.cwd() };
91
+ for (let i = 2; i < argv.length; i++) {
92
+ const a = argv[i];
93
+ if (a === "--dir" || a === "--pluginDir") {
94
+ out.dir = path.resolve(argv[++i] || ".");
95
+ continue;
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+
101
+ const args = parseArgs(process.argv);
102
+ const pluginDir = args.dir;
103
+ const pkgPath = path.join(pluginDir, "package.json");
104
+
105
+ if (!exists(pkgPath)) {
106
+ console.error(`[wiring] ERROR: missing package.json in ${pluginDir}`);
107
+ process.exit(2);
108
+ }
109
+
110
+ let pkg;
111
+ try {
112
+ pkg = JSON.parse(readText(pkgPath));
113
+ } catch (e) {
114
+ console.error(`[wiring] ERROR: invalid package.json (${pkgPath}): ${e?.message || e}`);
115
+ process.exit(2);
116
+ }
117
+
118
+ const cap = typeof pkg.capacitor === "object" && pkg.capacitor ? pkg.capacitor : {};
119
+ const supportsAndroid = typeof cap.android === "object" && cap.android;
120
+ const supportsIos = typeof cap.ios === "object" && cap.ios;
121
+
122
+ // Not a Capacitor plugin package (e.g. meta/workspace package).
123
+ // We only enforce wiring rules for actual plugin packages declaring a `capacitor` config.
124
+ if (!supportsAndroid && !supportsIos) {
125
+ process.exit(0);
126
+ }
127
+
128
+ // ---------------- JS (registerPlugin) ----------------
129
+ const jsSrcDir = path.join(pluginDir, "src");
130
+ let jsName = "";
131
+ if (exists(jsSrcDir)) {
132
+ const jsFiles = walkFiles(jsSrcDir, [".ts", ".js"]);
133
+ const reRegister = /registerPlugin(?:<[^>]*>)?\(\s*['"]([^'"]+)['"]/;
134
+ for (const f of jsFiles) {
135
+ const m = reRegister.exec(readText(f));
136
+ if (m) {
137
+ jsName = m[1];
138
+ break;
139
+ }
140
+ }
141
+ }
142
+
143
+ // ---------------- Android (@CapacitorPlugin) ----------------
144
+ let androidNames = [];
145
+ if (supportsAndroid) {
146
+ const androidMain = path.join(pluginDir, "android", "src", "main");
147
+ const files = walkFiles(androidMain, [".java", ".kt"]);
148
+ const foundAnnotations = [];
149
+ for (const f of files) {
150
+ const txt = readText(f);
151
+ if (!txt.includes("@CapacitorPlugin")) continue;
152
+ foundAnnotations.push(f);
153
+ const m =
154
+ /@CapacitorPlugin\(\s*name\s*=\s*"([^"]+)"/.exec(txt) ||
155
+ /@CapacitorPlugin\(\s*name\s*=\s*([A-Za-z0-9_]+)\b/.exec(txt);
156
+ if (m) androidNames.push(m[1]);
157
+ }
158
+ androidNames = uniq(androidNames);
159
+
160
+ // Enforce explicit name attribute to prevent silent class-name drift.
161
+ if (foundAnnotations.length && !androidNames.length) {
162
+ console.error(
163
+ `[wiring] ERROR: Android has @CapacitorPlugin but none specify name = \"...\". Add an explicit name to the plugin class.`
164
+ );
165
+ process.exit(1);
166
+ }
167
+ }
168
+
169
+ // ---------------- iOS (jsName) ----------------
170
+ let iosJsNames = [];
171
+ if (supportsIos) {
172
+ const iosDir = path.join(pluginDir, "ios");
173
+ const scanRoot = exists(path.join(iosDir, "Sources")) ? path.join(iosDir, "Sources") : iosDir;
174
+ const swiftFiles = walkFiles(scanRoot, [".swift"]);
175
+ const reJsName = /\bjsName\s*=\s*"([^"]+)"/g;
176
+ for (const f of swiftFiles) {
177
+ const txt = readText(f);
178
+ if (!txt.includes("jsName")) continue;
179
+ let m;
180
+ while ((m = reJsName.exec(txt))) iosJsNames.push(m[1]);
181
+ }
182
+ iosJsNames = uniq(iosJsNames);
183
+ }
184
+
185
+ // ---------------- Podspec/SPM ----------------
186
+ function parsePodspecName(podspecPath) {
187
+ const txt = readText(podspecPath);
188
+ const m = /\bs\.name\s*=\s*'([^']+)'/.exec(txt);
189
+ return m ? m[1] : "";
190
+ }
191
+
192
+ function parseSpmNames(packageSwiftPath) {
193
+ const txt = readText(packageSwiftPath);
194
+ const pkg = /Package\(\s*name\s*:\s*"([^"]+)"/.exec(txt)?.[1] || "";
195
+ const libs = [];
196
+ const reLib = /\.library\(\s*name\s*:\s*"([^"]+)"/g;
197
+ let m;
198
+ while ((m = reLib.exec(txt))) libs.push(m[1]);
199
+ return { pkgName: pkg, libNames: uniq(libs) };
200
+ }
201
+
202
+ // ---------------- Validate ----------------
203
+ const errors = [];
204
+
205
+ if (!jsName) {
206
+ errors.push("JS: no registerPlugin('...') found under src/");
207
+ }
208
+
209
+ if (supportsAndroid) {
210
+ if (!androidNames.length) errors.push("Android: missing @CapacitorPlugin(name = \"...\")");
211
+ if (jsName && androidNames.length && androidNames.some((n) => n !== jsName)) {
212
+ errors.push(`Android: @CapacitorPlugin(name)=${JSON.stringify(androidNames)} != JS registerPlugin=${jsName}`);
213
+ }
214
+ }
215
+
216
+ if (supportsIos) {
217
+ if (!iosJsNames.length) errors.push('iOS: missing jsName = "..." in Swift sources');
218
+ if (jsName && iosJsNames.length && iosJsNames.some((n) => n !== jsName)) {
219
+ errors.push(`iOS: jsName=${JSON.stringify(iosJsNames)} != JS registerPlugin=${jsName}`);
220
+ }
221
+
222
+ const podspecs = fs
223
+ .readdirSync(pluginDir, { withFileTypes: true })
224
+ .filter((e) => e.isFile() && e.name.endsWith(".podspec"))
225
+ .map((e) => path.join(pluginDir, e.name))
226
+ .sort();
227
+ if (!podspecs.length) errors.push("iOS: missing *.podspec at plugin root");
228
+ if (podspecs.length > 1) errors.push(`iOS: multiple podspecs at plugin root: ${podspecs.map((p) => path.basename(p))}`);
229
+
230
+ const pkgSwift = path.join(pluginDir, "Package.swift");
231
+ if (!exists(pkgSwift)) {
232
+ errors.push("iOS: missing Package.swift at plugin root");
233
+ } else if (podspecs.length) {
234
+ const podName = parsePodspecName(podspecs[0]);
235
+ const { pkgName, libNames } = parseSpmNames(pkgSwift);
236
+ if (!podName) errors.push("Podspec: missing s.name = '...'");
237
+ if (!pkgName) errors.push('SPM: missing Package(name: "...")');
238
+ if (podName && pkgName && podName !== pkgName) {
239
+ errors.push(`Podspec: s.name=${podName} != Package(name)=${pkgName}`);
240
+ }
241
+ if (pkgName && libNames.length && !libNames.includes(pkgName)) {
242
+ errors.push(`SPM: Package(name)=${pkgName} not present in .library(name) list ${JSON.stringify(libNames)}`);
243
+ }
244
+ }
245
+ }
246
+
247
+ if (errors.length) {
248
+ const relDir = path.relative(process.cwd(), pluginDir) || ".";
249
+ console.error(`[wiring] FAIL in ${relDir}`);
250
+ for (const e of errors) console.error(`- ${e}`);
251
+ process.exit(1);
252
+ }
253
+
254
+ process.exit(0);
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate a release changelog with Cloudflare Workers AI in CI.
4
+ * Invoked from build.yml on tag releases via `bun run changelog:ai`.
5
+ */
6
+
7
+ import { execFileSync } from 'node:child_process';
8
+ import { appendFileSync } from 'node:fs';
9
+
10
+ const DEFAULT_MODEL = '@cf/moonshotai/kimi-k2.7-code';
11
+
12
+ function parseArgs(argv) {
13
+ const args = { fromTag: process.env.FROM_TAG, toTag: process.env.TO_TAG };
14
+ for (let i = 0; i < argv.length; i++) {
15
+ if (argv[i] === '--from-tag') args.fromTag = argv[++i];
16
+ if (argv[i] === '--to-tag') args.toTag = argv[++i];
17
+ if (argv[i] === '--model') args.model = argv[++i];
18
+ }
19
+ return args;
20
+ }
21
+
22
+ function git(args) {
23
+ return execFileSync('/usr/bin/git', args, { encoding: 'utf8' }).trim();
24
+ }
25
+
26
+ function resolveTags({ fromTag, toTag }) {
27
+ const currentTag =
28
+ toTag ??
29
+ (process.env.GITHUB_REF?.startsWith('refs/tags/') ? process.env.GITHUB_REF.replace('refs/tags/', '') : null);
30
+
31
+ if (!currentTag) {
32
+ throw new Error('Missing target tag. Set GITHUB_REF to refs/tags/<tag> or pass --to-tag.');
33
+ }
34
+
35
+ const previousTag = fromTag ?? git(['describe', '--tags', '--abbrev=0', `${currentTag}^`]);
36
+ return { previousTag, currentTag };
37
+ }
38
+
39
+ function buildPrompt(previousTag, currentTag) {
40
+ const commits = git(['log', `${previousTag}..${currentTag}`, '--pretty=format:%s']);
41
+ const diffLines = git(['diff', '--stat', `${previousTag}..${currentTag}`]).split('\n');
42
+ const diffStat = diffLines.at(-1)?.trim() ?? '';
43
+
44
+ return `You are a technical writer creating a changelog for a software project.
45
+
46
+ There are changes in the git repository between two points:
47
+ - From tag: ${previousTag}
48
+ - To tag: ${currentTag}
49
+ - Commits:
50
+ ${commits || '(none)'}
51
+ - Diff summary: ${diffStat || '(no file changes)'}
52
+
53
+ ## Instructions:
54
+ 1. **Categorize changes** using standard changelog categories:
55
+ - **Added**: New features
56
+ - **Changed**: Changes in existing functionality
57
+ - **Deprecated**: Soon-to-be removed features
58
+ - **Removed**: Removed features
59
+ - **Fixed**: Bug fixes
60
+ - **Security**: Security improvements
61
+
62
+ 2. **Write clear, user-focused descriptions** that explain:
63
+ - What changed from a user's perspective
64
+ - Why it matters
65
+ - Any breaking changes or migration notes
66
+
67
+ 3. **Use consistent formatting**:
68
+ - Each item should be a concise bullet point
69
+ - Start with an action verb when possible
70
+ - Group related changes together
71
+
72
+ 4. **Focus on semantic meaning** rather than technical implementation details
73
+
74
+ Output ONLY the changelog content in markdown format. Do NOT include any explanatory text, metadata, introductory phrases like "Based on the git analysis" or "Here's the changelog", or concluding remarks. Start IMMEDIATELY with the changelog categories and items (e.g., "## Added"). Do not include version numbers or dates. Do not preface your response with any text whatsoever.`;
75
+ }
76
+
77
+ async function generateChangelog(prompt, model) {
78
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
79
+ const apiToken = process.env.CLOUDFLARE_API_TOKEN;
80
+
81
+ if (!accountId || !apiToken) {
82
+ throw new Error('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are required.');
83
+ }
84
+
85
+ const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${model}`;
86
+ const response = await fetch(url, {
87
+ method: 'POST',
88
+ headers: {
89
+ Authorization: `Bearer ${apiToken}`,
90
+ 'Content-Type': 'application/json',
91
+ },
92
+ body: JSON.stringify({
93
+ messages: [{ role: 'user', content: prompt }],
94
+ }),
95
+ });
96
+
97
+ const payload = await response.json();
98
+ if (!response.ok || !payload.success) {
99
+ throw new Error(`Cloudflare AI request failed with status ${response.status}`);
100
+ }
101
+
102
+ const content = payload.result?.choices?.[0]?.message?.content ?? payload.result?.response;
103
+ if (!content || typeof content !== 'string') {
104
+ throw new Error('Cloudflare AI returned an empty changelog response');
105
+ }
106
+
107
+ return content.trim();
108
+ }
109
+
110
+ function writeGithubOutput({ result, fromTag, toTag }) {
111
+ const outputFile = process.env.GITHUB_OUTPUT;
112
+ if (!outputFile) return;
113
+
114
+ const delimiter = `changelog_${Date.now()}`;
115
+ appendFileSync(outputFile, `result<<${delimiter}\n${result}\n${delimiter}\n`);
116
+ appendFileSync(outputFile, `from_tag=${fromTag}\n`);
117
+ appendFileSync(outputFile, `to_tag=${toTag}\n`);
118
+ }
119
+
120
+ const args = parseArgs(process.argv.slice(2));
121
+ const model = args.model ?? process.env.CLOUDFLARE_AI_MODEL ?? DEFAULT_MODEL;
122
+ const { previousTag, currentTag } = resolveTags(args);
123
+ const prompt = buildPrompt(previousTag, currentTag);
124
+
125
+ console.error(`Generating changelog with ${model}`);
126
+ console.error(`Range: ${previousTag}..${currentTag}`);
127
+
128
+ try {
129
+ const result = await generateChangelog(prompt, model);
130
+ writeGithubOutput({ result, fromTag: previousTag, toTag: currentTag });
131
+ } catch (error) {
132
+ const message = error instanceof Error ? error.message : 'Unknown error';
133
+ console.error(`Changelog generation failed: ${message}`);
134
+ process.exit(1);
135
+ }
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ cd "$ROOT_DIR"
6
+
7
+ SIMULATOR_ID=$(xcrun simctl list devices available | awk -F '[()]' '/iPhone/{print $2; exit}')
8
+
9
+ if [[ -z "${SIMULATOR_ID:-}" ]]; then
10
+ echo "No available iPhone simulator found. Please install one via Xcode." >&2
11
+ exit 1
12
+ fi
13
+
14
+ DESTINATION="id=${SIMULATOR_ID}"
15
+ SCHEME="CapgoCapacitorVideoPlayer"
16
+ SCRIPT_TIMEOUT_SECONDS=600
17
+ DEADLINE=$((SECONDS + SCRIPT_TIMEOUT_SECONDS))
18
+
19
+ BUILD_ARGS=()
20
+ TEST_ARGS=()
21
+ while [[ $# -gt 0 ]]; do
22
+ case "$1" in
23
+ -only-testing|-skip-testing|-resultBundlePath)
24
+ TEST_ARGS+=("$1")
25
+ shift
26
+ if [[ $# -gt 0 ]]; then
27
+ TEST_ARGS+=("$1")
28
+ shift
29
+ fi
30
+ ;;
31
+ -testPlan)
32
+ BUILD_ARGS+=("$1")
33
+ TEST_ARGS+=("$1")
34
+ shift
35
+ if [[ $# -gt 0 ]]; then
36
+ BUILD_ARGS+=("$1")
37
+ TEST_ARGS+=("$1")
38
+ shift
39
+ fi
40
+ ;;
41
+ -only-testing:*|-skip-testing:*|-resultBundlePath=*)
42
+ TEST_ARGS+=("$1")
43
+ shift
44
+ ;;
45
+ -testPlan=*)
46
+ BUILD_ARGS+=("$1")
47
+ TEST_ARGS+=("$1")
48
+ shift
49
+ ;;
50
+ *)
51
+ BUILD_ARGS+=("$1")
52
+ TEST_ARGS+=("$1")
53
+ shift
54
+ ;;
55
+ esac
56
+ done
57
+
58
+ timeout_dir=$(mktemp -d "${TMPDIR:-/tmp}/test-ios-timeout.XXXXXX")
59
+ timeout_marker="${timeout_dir}/timed-out"
60
+ trap 'rm -rf "$timeout_dir"' EXIT
61
+
62
+ run_with_timeout() {
63
+ local remaining=$((DEADLINE - SECONDS))
64
+ if [[ "$remaining" -le 0 ]]; then
65
+ echo "error: xcodebuild timed out after ${SCRIPT_TIMEOUT_SECONDS}s" >&2
66
+ : >"$timeout_marker"
67
+ return 124
68
+ fi
69
+
70
+ set +e
71
+ (
72
+ set -m
73
+ "$@" &
74
+ local pid=$!
75
+ (
76
+ sleep "$remaining"
77
+ if kill -0 "$pid" 2>/dev/null; then
78
+ echo "error: xcodebuild timed out after ${SCRIPT_TIMEOUT_SECONDS}s" >&2
79
+ : >"$timeout_marker"
80
+ kill -TERM -"$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null
81
+ sleep 2
82
+ kill -KILL -"$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null
83
+ fi
84
+ ) &
85
+ local watchdog=$!
86
+ wait "$pid"
87
+ local status=$?
88
+ kill "$watchdog" 2>/dev/null || true
89
+ wait "$watchdog" 2>/dev/null || true
90
+ exit "$status"
91
+ )
92
+ local status=$?
93
+ set -e
94
+
95
+ if [[ -f "$timeout_marker" ]]; then
96
+ return 124
97
+ fi
98
+ return "$status"
99
+ }
100
+
101
+ run_with_timeout xcodebuild build-for-testing \
102
+ -scheme "$SCHEME" \
103
+ -destination "$DESTINATION" \
104
+ "${BUILD_ARGS[@]}"
105
+
106
+ run_with_timeout xcodebuild test-without-building \
107
+ -scheme "$SCHEME" \
108
+ -destination "$DESTINATION" \
109
+ "${TEST_ARGS[@]}"