capacitor-mobilecron 0.1.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,32 @@
1
+ import { type PluginListenerHandle, WebPlugin } from '@capacitor/core';
2
+ import type { CronJobOptions, CronStatus, JobDueEvent, JobSkippedEvent, MobileCronPlugin, OverdueEvent, SchedulingMode } from './definitions';
3
+ export declare class MobileCronWeb extends WebPlugin implements MobileCronPlugin {
4
+ private readonly scheduler;
5
+ private readonly ready;
6
+ constructor();
7
+ register(options: CronJobOptions): Promise<{
8
+ id: string;
9
+ }>;
10
+ unregister(options: {
11
+ id: string;
12
+ }): Promise<void>;
13
+ update(options: {
14
+ id: string;
15
+ } & Partial<CronJobOptions>): Promise<void>;
16
+ list(): Promise<{
17
+ jobs: import('./definitions').CronJobStatus[];
18
+ }>;
19
+ triggerNow(options: {
20
+ id: string;
21
+ }): Promise<void>;
22
+ pauseAll(): Promise<void>;
23
+ resumeAll(): Promise<void>;
24
+ setMode(options: {
25
+ mode: SchedulingMode;
26
+ }): Promise<void>;
27
+ getStatus(): Promise<CronStatus>;
28
+ addListener(event: 'jobDue', handler: (data: JobDueEvent) => void): Promise<PluginListenerHandle>;
29
+ addListener(event: 'jobSkipped', handler: (data: JobSkippedEvent) => void): Promise<PluginListenerHandle>;
30
+ addListener(event: 'overdueJobs', handler: (data: OverdueEvent) => void): Promise<PluginListenerHandle>;
31
+ addListener(event: 'statusChanged', handler: (data: CronStatus) => void): Promise<PluginListenerHandle>;
32
+ }
@@ -0,0 +1,54 @@
1
+ import { WebPlugin } from '@capacitor/core';
2
+ import { MobileCronScheduler } from './mobilecron';
3
+ export class MobileCronWeb extends WebPlugin {
4
+ constructor() {
5
+ super();
6
+ this.scheduler = new MobileCronScheduler({
7
+ platform: 'web',
8
+ onJobDue: (event) => this.notifyListeners('jobDue', event),
9
+ onJobSkipped: (event) => this.notifyListeners('jobSkipped', event),
10
+ onOverdue: (event) => this.notifyListeners('overdueJobs', event),
11
+ onStatusChanged: (status) => this.notifyListeners('statusChanged', status),
12
+ });
13
+ this.ready = this.scheduler.init();
14
+ }
15
+ async register(options) {
16
+ await this.ready;
17
+ return this.scheduler.register(options);
18
+ }
19
+ async unregister(options) {
20
+ await this.ready;
21
+ return this.scheduler.unregister(options);
22
+ }
23
+ async update(options) {
24
+ await this.ready;
25
+ return this.scheduler.update(options);
26
+ }
27
+ async list() {
28
+ await this.ready;
29
+ return this.scheduler.list();
30
+ }
31
+ async triggerNow(options) {
32
+ await this.ready;
33
+ return this.scheduler.triggerNow(options);
34
+ }
35
+ async pauseAll() {
36
+ await this.ready;
37
+ return this.scheduler.pauseAll();
38
+ }
39
+ async resumeAll() {
40
+ await this.ready;
41
+ return this.scheduler.resumeAll();
42
+ }
43
+ async setMode(options) {
44
+ await this.ready;
45
+ return this.scheduler.setMode(options.mode);
46
+ }
47
+ async getStatus() {
48
+ await this.ready;
49
+ return this.scheduler.getStatus();
50
+ }
51
+ async addListener(eventName, listenerFunc) {
52
+ return super.addListener(eventName, listenerFunc);
53
+ }
54
+ }
@@ -0,0 +1,78 @@
1
+ import Foundation
2
+ import BackgroundTasks
3
+
4
+ final class BGTaskManager {
5
+ struct Status {
6
+ var bgRefreshRegistered: Bool = false
7
+ var bgProcessingRegistered: Bool = false
8
+ var bgContinuedAvailable: Bool = false
9
+ }
10
+
11
+ private weak var plugin: MobileCronPlugin?
12
+ private(set) var status = Status()
13
+
14
+ init(plugin: MobileCronPlugin) {
15
+ self.plugin = plugin
16
+ self.status.bgContinuedAvailable = false
17
+ }
18
+
19
+ func registerBGTasks() {
20
+ status.bgRefreshRegistered = BGTaskScheduler.shared.register(
21
+ forTaskWithIdentifier: "io.mobilecron.refresh",
22
+ using: nil
23
+ ) { [weak self] task in
24
+ guard let refreshTask = task as? BGAppRefreshTask else {
25
+ task.setTaskCompleted(success: false)
26
+ return
27
+ }
28
+ self?.handleRefresh(refreshTask)
29
+ }
30
+
31
+ status.bgProcessingRegistered = BGTaskScheduler.shared.register(
32
+ forTaskWithIdentifier: "io.mobilecron.processing",
33
+ using: nil
34
+ ) { [weak self] task in
35
+ guard let processingTask = task as? BGProcessingTask else {
36
+ task.setTaskCompleted(success: false)
37
+ return
38
+ }
39
+ self?.handleProcessing(processingTask)
40
+ }
41
+
42
+ // iOS 26+ BGContinuedProcessingTask placeholder: keep runtime feature flag separate from compile-time SDK use.
43
+ status.bgContinuedAvailable = false
44
+ }
45
+
46
+ func scheduleRefresh() {
47
+ let request = BGAppRefreshTaskRequest(identifier: "io.mobilecron.refresh")
48
+ request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
49
+ try? BGTaskScheduler.shared.submit(request)
50
+ }
51
+
52
+ func scheduleProcessing(requiresExternalPower: Bool) {
53
+ let request = BGProcessingTaskRequest(identifier: "io.mobilecron.processing")
54
+ request.requiresExternalPower = requiresExternalPower
55
+ request.requiresNetworkConnectivity = false
56
+ request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
57
+ try? BGTaskScheduler.shared.submit(request)
58
+ }
59
+
60
+ private func handleRefresh(_ task: BGAppRefreshTask) {
61
+ scheduleRefresh()
62
+ task.expirationHandler = {
63
+ task.setTaskCompleted(success: false)
64
+ }
65
+ plugin?.handleBackgroundWake(source: "bgtask_refresh")
66
+ task.setTaskCompleted(success: true)
67
+ }
68
+
69
+ private func handleProcessing(_ task: BGProcessingTask) {
70
+ let mode = plugin?.currentMode ?? "balanced"
71
+ scheduleProcessing(requiresExternalPower: mode != "aggressive")
72
+ task.expirationHandler = {
73
+ task.setTaskCompleted(success: false)
74
+ }
75
+ plugin?.handleBackgroundWake(source: "bgtask_processing")
76
+ task.setTaskCompleted(success: true)
77
+ }
78
+ }
@@ -0,0 +1,13 @@
1
+ #import <Capacitor/Capacitor.h>
2
+
3
+ CAP_PLUGIN(MobileCronPlugin, "MobileCron",
4
+ CAP_PLUGIN_METHOD(register, CAPPluginReturnPromise);
5
+ CAP_PLUGIN_METHOD(unregister, CAPPluginReturnPromise);
6
+ CAP_PLUGIN_METHOD(update, CAPPluginReturnPromise);
7
+ CAP_PLUGIN_METHOD(list, CAPPluginReturnPromise);
8
+ CAP_PLUGIN_METHOD(triggerNow, CAPPluginReturnPromise);
9
+ CAP_PLUGIN_METHOD(pauseAll, CAPPluginReturnPromise);
10
+ CAP_PLUGIN_METHOD(resumeAll, CAPPluginReturnPromise);
11
+ CAP_PLUGIN_METHOD(setMode, CAPPluginReturnPromise);
12
+ CAP_PLUGIN_METHOD(getStatus, CAPPluginReturnPromise);
13
+ )
@@ -0,0 +1,169 @@
1
+ import Foundation
2
+ import Capacitor
3
+
4
+ @objc(MobileCronPlugin)
5
+ public class MobileCronPlugin: CAPPlugin, CAPBridgedPlugin {
6
+ public let identifier = "MobileCronPlugin"
7
+ public let jsName = "MobileCron"
8
+ public let pluginMethods: [CAPPluginMethod] = [
9
+ CAPPluginMethod(name: "register", returnType: CAPPluginReturnPromise),
10
+ CAPPluginMethod(name: "unregister", returnType: CAPPluginReturnPromise),
11
+ CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
12
+ CAPPluginMethod(name: "list", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "triggerNow", returnType: CAPPluginReturnPromise),
14
+ CAPPluginMethod(name: "pauseAll", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "resumeAll", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "setMode", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise)
18
+ ]
19
+
20
+ private var jobs: [String: [String: Any]] = [:]
21
+ private var paused = false
22
+ private(set) var mode = "balanced"
23
+ var currentMode: String { mode }
24
+ private var bgManager: BGTaskManager?
25
+
26
+ public override func load() {
27
+ super.load()
28
+ let manager = BGTaskManager(plugin: self)
29
+ manager.registerBGTasks()
30
+ manager.scheduleRefresh()
31
+ manager.scheduleProcessing(requiresExternalPower: true)
32
+ self.bgManager = manager
33
+ }
34
+
35
+ func handleBackgroundWake(source: String) {
36
+ notifyListeners("statusChanged", data: buildStatus())
37
+ notifyListeners("nativeWake", data: ["source": source, "paused": paused])
38
+ }
39
+
40
+ @objc func register(_ call: CAPPluginCall) {
41
+ guard let name = call.getString("name")?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else {
42
+ call.reject("Job name is required")
43
+ return
44
+ }
45
+
46
+ let id = UUID().uuidString
47
+ var record: [String: Any] = [
48
+ "id": id,
49
+ "name": name,
50
+ "enabled": true,
51
+ "consecutiveSkips": 0
52
+ ]
53
+ if let schedule = call.getObject("schedule") { record["schedule"] = schedule }
54
+ if let activeHours = call.getObject("activeHours") { record["activeHours"] = activeHours }
55
+ if call.options.keys.contains("requiresNetwork") { record["requiresNetwork"] = call.getBool("requiresNetwork") ?? false }
56
+ if call.options.keys.contains("requiresCharging") { record["requiresCharging"] = call.getBool("requiresCharging") ?? false }
57
+ if let priority = call.getString("priority") { record["priority"] = priority }
58
+ if let data = call.getObject("data") { record["data"] = data }
59
+
60
+ jobs[id] = record
61
+ notifyListeners("statusChanged", data: buildStatus())
62
+ call.resolve(["id": id])
63
+ }
64
+
65
+ @objc func unregister(_ call: CAPPluginCall) {
66
+ guard let id = call.getString("id") else {
67
+ call.reject("id is required")
68
+ return
69
+ }
70
+ jobs.removeValue(forKey: id)
71
+ notifyListeners("statusChanged", data: buildStatus())
72
+ call.resolve()
73
+ }
74
+
75
+ @objc func update(_ call: CAPPluginCall) {
76
+ guard let id = call.getString("id") else {
77
+ call.reject("id is required")
78
+ return
79
+ }
80
+ guard var existing = jobs[id] else {
81
+ call.reject("Job not found")
82
+ return
83
+ }
84
+
85
+ if let name = call.getString("name") { existing["name"] = name }
86
+ if let schedule = call.getObject("schedule") { existing["schedule"] = schedule }
87
+ if call.options.keys.contains("activeHours") { existing["activeHours"] = call.getObject("activeHours") }
88
+ if call.options.keys.contains("requiresNetwork") { existing["requiresNetwork"] = call.getBool("requiresNetwork") ?? false }
89
+ if call.options.keys.contains("requiresCharging") { existing["requiresCharging"] = call.getBool("requiresCharging") ?? false }
90
+ if let priority = call.getString("priority") { existing["priority"] = priority }
91
+ if call.options.keys.contains("data") { existing["data"] = call.getObject("data") }
92
+
93
+ jobs[id] = existing
94
+ notifyListeners("statusChanged", data: buildStatus())
95
+ call.resolve()
96
+ }
97
+
98
+ @objc func list(_ call: CAPPluginCall) {
99
+ call.resolve(["jobs": Array(jobs.values)])
100
+ }
101
+
102
+ @objc func triggerNow(_ call: CAPPluginCall) {
103
+ guard let id = call.getString("id") else {
104
+ call.reject("id is required")
105
+ return
106
+ }
107
+ guard let job = jobs[id] else {
108
+ call.reject("Job not found")
109
+ return
110
+ }
111
+
112
+ var payload: [String: Any] = [
113
+ "id": id,
114
+ "name": (job["name"] as? String) ?? "",
115
+ "firedAt": Int(Date().timeIntervalSince1970 * 1000),
116
+ "source": "manual"
117
+ ]
118
+ if let data = job["data"] {
119
+ payload["data"] = data
120
+ }
121
+ notifyListeners("jobDue", data: payload)
122
+ call.resolve()
123
+ }
124
+
125
+ @objc func pauseAll(_ call: CAPPluginCall) {
126
+ paused = true
127
+ notifyListeners("statusChanged", data: buildStatus())
128
+ call.resolve()
129
+ }
130
+
131
+ @objc func resumeAll(_ call: CAPPluginCall) {
132
+ paused = false
133
+ notifyListeners("statusChanged", data: buildStatus())
134
+ call.resolve()
135
+ }
136
+
137
+ @objc func setMode(_ call: CAPPluginCall) {
138
+ guard let mode = call.getString("mode"), ["eco", "balanced", "aggressive"].contains(mode) else {
139
+ call.reject("mode must be eco|balanced|aggressive")
140
+ return
141
+ }
142
+ self.mode = mode
143
+ if let bgManager {
144
+ bgManager.scheduleRefresh()
145
+ bgManager.scheduleProcessing(requiresExternalPower: mode != "aggressive")
146
+ }
147
+ notifyListeners("statusChanged", data: buildStatus())
148
+ call.resolve()
149
+ }
150
+
151
+ @objc func getStatus(_ call: CAPPluginCall) {
152
+ call.resolve(buildStatus())
153
+ }
154
+
155
+ private func buildStatus() -> [String: Any] {
156
+ let diagnostics = bgManager?.status ?? .init()
157
+ return [
158
+ "paused": paused,
159
+ "mode": mode,
160
+ "platform": "ios",
161
+ "activeJobCount": jobs.count,
162
+ "ios": [
163
+ "bgRefreshRegistered": diagnostics.bgRefreshRegistered,
164
+ "bgProcessingRegistered": diagnostics.bgProcessingRegistered,
165
+ "bgContinuedAvailable": diagnostics.bgContinuedAvailable
166
+ ]
167
+ ]
168
+ }
169
+ }
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "capacitor-mobilecron",
3
+ "version": "0.1.0",
4
+ "description": "Capacitor scheduling primitive that emits job due events across web, Android, and iOS",
5
+ "main": "dist/esm/index.js",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/esm/index.d.ts",
8
+ "capacitor": {
9
+ "android": {
10
+ "src": "android"
11
+ },
12
+ "ios": {
13
+ "src": "ios"
14
+ }
15
+ },
16
+ "files": [
17
+ "android/src",
18
+ "android/build.gradle",
19
+ "android/proguard-rules.pro",
20
+ "ios",
21
+ "dist",
22
+ "src",
23
+ "package.json",
24
+ "README.md"
25
+ ],
26
+ "author": "Rogelio Ruiz",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/rogelioRuiz/capacitor-mobilecron.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/rogelioRuiz/capacitor-mobilecron/issues"
34
+ },
35
+ "homepage": "https://github.com/rogelioRuiz/capacitor-mobilecron#readme",
36
+ "keywords": [
37
+ "capacitor",
38
+ "plugin",
39
+ "scheduler",
40
+ "cron",
41
+ "mobile",
42
+ "background",
43
+ "jobs",
44
+ "android",
45
+ "ios"
46
+ ],
47
+ "engines": {
48
+ "node": ">=18"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -p tsconfig.json",
52
+ "build:watch": "tsc -p tsconfig.json --watch",
53
+ "clean": "rm -rf dist",
54
+ "typecheck": "tsc -p tsconfig.json --noEmit",
55
+ "lint": "npx biome check .",
56
+ "lint:fix": "npx biome check --write .",
57
+ "format": "npx biome format --write .",
58
+ "test": "vitest run",
59
+ "test:watch": "vitest",
60
+ "test:coverage": "vitest run --coverage",
61
+ "test:e2e": "node tests/e2e/test-e2e.mjs",
62
+ "prepack": "npm run build"
63
+ },
64
+ "peerDependencies": {
65
+ "@capacitor/core": ">=6.0.0",
66
+ "@capacitor/preferences": ">=6.0.0",
67
+ "@capacitor/app": ">=6.0.0"
68
+ },
69
+ "peerDependenciesMeta": {
70
+ "@capacitor/app": {
71
+ "optional": true
72
+ }
73
+ },
74
+ "devDependencies": {
75
+ "@biomejs/biome": "^1.9.4",
76
+ "@capacitor/app": "^6.0.0",
77
+ "@capacitor/core": "^6.0.0",
78
+ "@capacitor/preferences": "^6.0.0",
79
+ "@vitest/coverage-v8": "^3.2.4",
80
+ "happy-dom": "^17.5.5",
81
+ "typescript": "^5.6.3",
82
+ "vitest": "^3.2.4"
83
+ }
84
+ }
@@ -0,0 +1,99 @@
1
+ import type { PluginListenerHandle } from '@capacitor/core'
2
+
3
+ export interface MobileCronPlugin {
4
+ register(options: CronJobOptions): Promise<{ id: string }>
5
+ unregister(options: { id: string }): Promise<void>
6
+ update(options: { id: string } & Partial<CronJobOptions>): Promise<void>
7
+ list(): Promise<{ jobs: CronJobStatus[] }>
8
+ triggerNow(options: { id: string }): Promise<void>
9
+
10
+ pauseAll(): Promise<void>
11
+ resumeAll(): Promise<void>
12
+ setMode(options: { mode: SchedulingMode }): Promise<void>
13
+ getStatus(): Promise<CronStatus>
14
+
15
+ addListener(event: 'jobDue', handler: (data: JobDueEvent) => void): Promise<PluginListenerHandle>
16
+ addListener(event: 'jobSkipped', handler: (data: JobSkippedEvent) => void): Promise<PluginListenerHandle>
17
+ addListener(event: 'overdueJobs', handler: (data: OverdueEvent) => void): Promise<PluginListenerHandle>
18
+ addListener(event: 'statusChanged', handler: (data: CronStatus) => void): Promise<PluginListenerHandle>
19
+ }
20
+
21
+ export interface CronJobOptions {
22
+ name: string
23
+ schedule: CronSchedule
24
+ activeHours?: ActiveHours
25
+ requiresNetwork?: boolean
26
+ requiresCharging?: boolean
27
+ priority?: 'low' | 'normal' | 'high'
28
+ data?: Record<string, unknown>
29
+ }
30
+
31
+ export interface CronSchedule {
32
+ kind: 'every' | 'at'
33
+ everyMs?: number
34
+ anchorMs?: number
35
+ atMs?: number
36
+ }
37
+
38
+ export interface ActiveHours {
39
+ start: string
40
+ end: string
41
+ tz?: string
42
+ }
43
+
44
+ export type SchedulingMode = 'eco' | 'balanced' | 'aggressive'
45
+
46
+ export interface CronJobStatus {
47
+ id: string
48
+ name: string
49
+ enabled: boolean
50
+ schedule: CronSchedule
51
+ lastFiredAt?: number
52
+ nextDueAt?: number
53
+ consecutiveSkips: number
54
+ data?: Record<string, unknown>
55
+ }
56
+
57
+ export interface CronStatus {
58
+ paused: boolean
59
+ mode: SchedulingMode
60
+ platform: 'android' | 'ios' | 'web'
61
+ activeJobCount: number
62
+ nextDueAt?: number
63
+ android?: { workManagerActive: boolean; chargingReceiverActive: boolean }
64
+ ios?: {
65
+ bgRefreshRegistered: boolean
66
+ bgProcessingRegistered: boolean
67
+ bgContinuedAvailable: boolean
68
+ }
69
+ }
70
+
71
+ export interface JobDueEvent {
72
+ id: string
73
+ name: string
74
+ firedAt: number
75
+ source: WakeSource
76
+ data?: Record<string, unknown>
77
+ }
78
+
79
+ export type WakeSource =
80
+ | 'watchdog'
81
+ | 'workmanager'
82
+ | 'workmanager_chain'
83
+ | 'charging'
84
+ | 'foreground'
85
+ | 'bgtask_refresh'
86
+ | 'bgtask_processing'
87
+ | 'bgtask_continued'
88
+ | 'manual'
89
+
90
+ export interface JobSkippedEvent {
91
+ id: string
92
+ name: string
93
+ reason: 'outside_active_hours' | 'paused' | 'requires_network' | 'requires_charging'
94
+ }
95
+
96
+ export interface OverdueEvent {
97
+ count: number
98
+ jobs: Array<{ id: string; name: string; overdueMs: number }>
99
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './definitions'
2
+ export * from './mobilecron'
3
+ export { MobileCron } from './plugin'