capacitor-app-lifecycle 0.0.0-stage → 0.0.1

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,17 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'CapacitorAppLifecycle'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.license = package['license']
10
+ s.homepage = package['repository']['url']
11
+ s.author = package['author']
12
+ s.source = { :git => package['repository']['url'], :tag => s.version.to_s }
13
+ s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
14
+ s.ios.deployment_target = '15.0'
15
+ s.dependency 'Capacitor'
16
+ s.swift_version = '5.1'
17
+ end
package/Package.swift ADDED
@@ -0,0 +1,28 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "CapacitorAppLifecycle",
6
+ platforms: [.iOS(.v15)],
7
+ products: [
8
+ .library(
9
+ name: "CapacitorAppLifecycle",
10
+ targets: ["AppLifecyclePlugin"])
11
+ ],
12
+ dependencies: [
13
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
14
+ ],
15
+ targets: [
16
+ .target(
17
+ name: "AppLifecyclePlugin",
18
+ dependencies: [
19
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
20
+ .product(name: "Cordova", package: "capacitor-swift-pm")
21
+ ],
22
+ path: "ios/Sources/AppLifecyclePlugin"),
23
+ .testTarget(
24
+ name: "AppLifecyclePluginTests",
25
+ dependencies: ["AppLifecyclePlugin"],
26
+ path: "ios/Tests/AppLifecyclePluginTests")
27
+ ]
28
+ )
package/README.md CHANGED
@@ -1,3 +1,141 @@
1
- # Temporary Holding Version
1
+ # capacitor-app-lifecycle
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Close a Capacitor app for good, from JavaScript, on iOS and Android.
4
+
5
+ Sometimes an app gets into a state it cannot fix while it is running: a local database that has to be rebuilt, a native SDK that
6
+ has to start again, a migration that needs a clean start. The usual way out is to ask the user to close the app and open it again.
7
+ This plugin does the closing part. The process ends, and the next time the user taps the icon the app starts from scratch.
8
+
9
+ It is a clean exit, not a crash. Crashing on purpose is a common workaround, and it fills your crash reporter with noise you then
10
+ have to filter out. This plugin does not do that.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install capacitor-app-lifecycle
16
+ npx cap sync
17
+ ```
18
+
19
+ Requires Capacitor 8. Android 8.0 (API 26) or newer, iOS 15 or newer. On iOS the plugin is a Swift package; CocoaPods works too.
20
+
21
+ ## Use
22
+
23
+ ```typescript
24
+ import { AppLifecycle } from 'capacitor-app-lifecycle';
25
+
26
+ async function restartFromScratch() {
27
+ // Tell the user first: the app disappears as soon as this runs.
28
+ await AppLifecycle.forceClose();
29
+ }
30
+ ```
31
+
32
+ Do anything that has to happen first (saving, flushing logs, telling the user) **before** the call. Nothing after it is
33
+ guaranteed to run.
34
+
35
+ ## How it closes the app
36
+
37
+ The two platforms do it differently, and that shows up in the promise.
38
+
39
+ **iOS.** The promise resolves, then about 0.3 seconds later the process calls `exit(0)` on the main thread. The short wait lets the
40
+ result reach JavaScript and lets any running animation finish, so code right after the `await` starts to run, but there is no
41
+ promise it finishes.
42
+
43
+ **Android.** The activity task is finished with `finishAffinity()`, which takes the app off the recents screen, and then the process
44
+ is killed. The promise is resolved before that, but the process usually goes down before the answer reaches JavaScript, so on
45
+ Android the `await` normally never returns. Do not wait on it for anything.
46
+
47
+ Calling `forceClose()` again while an exit is already on its way does not schedule a second one.
48
+
49
+ On the web the call rejects with the `UNIMPLEMENTED` error code. A page cannot close its own browser tab, so decide what your app
50
+ should do there instead, for example reload:
51
+
52
+ ```typescript
53
+ import { Capacitor } from '@capacitor/core';
54
+
55
+ if (Capacitor.isNativePlatform()) {
56
+ await AppLifecycle.forceClose();
57
+ } else {
58
+ window.location.href = '/';
59
+ }
60
+ ```
61
+
62
+ ### A note on App Store review
63
+
64
+ Apple's guidelines discourage apps that quit on their own. Use this for recovery, after telling the user what is about to happen,
65
+ not as a normal way to leave the app.
66
+
67
+ ## Coming from the Cordova plugin
68
+
69
+ If you used a Cordova plugin that exposed `cordova.plugins.appLifecycle.forceClose()`, the call maps one to one:
70
+
71
+ | Cordova | Capacitor |
72
+ | --------------------------------------------------------- | --------------------------------- |
73
+ | `cordova.plugins.appLifecycle.forceClose(success, error)` | `await AppLifecycle.forceClose()` |
74
+
75
+ The native behaviour is the same: `exit(0)` after 0.3 seconds on iOS, `finishAffinity()` and a process kill on Android.
76
+
77
+ ## API
78
+
79
+ <docgen-index>
80
+
81
+ * [`forceClose()`](#forceclose)
82
+
83
+ </docgen-index>
84
+
85
+ <docgen-api>
86
+ <!--Update the source file JSDoc comments and rerun docgen to update the docs below-->
87
+
88
+ ### forceClose()
89
+
90
+ ```typescript
91
+ forceClose() => Promise<void>
92
+ ```
93
+
94
+ Terminates the app process, so that the next launch starts from scratch.
95
+
96
+ Use it when the app is in a state it cannot recover from in place, for
97
+ example a local database that has to be rebuilt, and the user should simply
98
+ open the app again. It is a clean exit, not a crash: nothing is reported to
99
+ a crash reporter.
100
+
101
+ The two platforms end the process differently, and the promise behaves
102
+ accordingly:
103
+
104
+ - **iOS**: the promise resolves, and the process exits with `exit(0)` about
105
+ 0.3 seconds later, on the main thread.
106
+ - **Android**: the activity task is finished with `finishAffinity()` and the
107
+ process is killed straight away. The promise is resolved first, but the
108
+ process usually goes down before the result reaches JavaScript, so do not
109
+ wait on it for anything.
110
+
111
+ Calling it again while an exit is already on its way resolves without
112
+ scheduling a second one.
113
+
114
+ On the web it rejects with the `UNIMPLEMENTED` code.
115
+
116
+ **Since:** 1.0.0
117
+
118
+ --------------------
119
+
120
+ </docgen-api>
121
+
122
+ ## Testing
123
+
124
+ Everything that can be tested without ending the test process is tested on every change:
125
+
126
+ ```bash
127
+ npm run test:unit # TypeScript: the web rejects, native calls reach the right plugin and method
128
+ npm run test:parity # the API, the Swift, the Kotlin and this README name the same methods
129
+ npm run test:android # JUnit on the JVM: the order of the steps, the double-call guard
130
+ npm run test:ios # XCTest on a simulator: the delay, the double-call guard, the main thread
131
+ ```
132
+
133
+ The native code takes the scheduler and the exit as arguments, so the tests hand it fakes and check what it would have done.
134
+
135
+ What the tests cannot show is the process actually ending, because that would end the test too. That part needs a real app on a
136
+ phone or a simulator: call `forceClose()`, check that the process is gone (`adb shell pidof <package>` prints nothing on Android;
137
+ on iOS the app is no longer running), and check that no crash was reported. The example app in `example-app/` has a button for it.
138
+
139
+ ## License
140
+
141
+ MIT
@@ -0,0 +1,64 @@
1
+ ext {
2
+ junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
3
+ androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
4
+ androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.3.0'
5
+ androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.7.0'
6
+ androidxCoreKTXVersion = project.hasProperty('androidxCoreKTXVersion') ? rootProject.ext.androidxCoreKTXVersion : '1.17.0'
7
+ }
8
+
9
+ buildscript {
10
+ ext.kotlin_version = project.hasProperty("kotlin_version") ? rootProject.ext.kotlin_version : '2.2.20'
11
+ repositories {
12
+ google()
13
+ mavenCentral()
14
+ }
15
+ dependencies {
16
+ classpath 'com.android.tools.build:gradle:8.13.0'
17
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
18
+ }
19
+ }
20
+
21
+ apply plugin: 'com.android.library'
22
+ apply plugin: 'kotlin-android'
23
+
24
+ android {
25
+ namespace = "io.github.maximbelov.applifecycle"
26
+ compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
27
+ defaultConfig {
28
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 26
29
+ targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
30
+ versionCode 1
31
+ versionName "1.0"
32
+ }
33
+ buildTypes {
34
+ release {
35
+ minifyEnabled false
36
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
37
+ }
38
+ }
39
+ lintOptions {
40
+ abortOnError = false
41
+ }
42
+ compileOptions {
43
+ sourceCompatibility JavaVersion.VERSION_21
44
+ targetCompatibility JavaVersion.VERSION_21
45
+ }
46
+ }
47
+
48
+ kotlin {
49
+ jvmToolchain(21)
50
+ }
51
+
52
+ repositories {
53
+ google()
54
+ mavenCentral()
55
+ }
56
+
57
+
58
+ dependencies {
59
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
60
+ implementation project(':capacitor-android')
61
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
62
+ implementation "androidx.core:core-ktx:$androidxCoreKTXVersion"
63
+ testImplementation "junit:junit:$junitVersion"
64
+ }
@@ -0,0 +1,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -0,0 +1,38 @@
1
+ package io.github.maximbelov.applifecycle
2
+
3
+ /**
4
+ * Ends the app process: finishes the activity task, then kills the process.
5
+ *
6
+ * The three steps are passed in so that the order and the guard can be tested on the JVM without ending the test process.
7
+ * [AppLifecyclePlugin] passes the real ones.
8
+ */
9
+ class AppLifecycle(
10
+ private val runOnMainThread: (Runnable) -> Unit,
11
+ private val finishTask: () -> Unit,
12
+ private val killProcess: () -> Unit,
13
+ ) {
14
+ private var closing = false
15
+
16
+ /**
17
+ * Schedules the exit on the main thread. Returns false, and schedules nothing, when an exit is already scheduled.
18
+ */
19
+ @Synchronized
20
+ fun forceClose(): Boolean {
21
+ if (closing) {
22
+ return false
23
+ }
24
+ closing = true
25
+ runOnMainThread(
26
+ Runnable {
27
+ // Finishing the task first removes it from the recents screen; killing the process is what actually ends it,
28
+ // because finishAffinity() on its own leaves the process (and the web view) alive.
29
+ try {
30
+ finishTask()
31
+ } finally {
32
+ killProcess()
33
+ }
34
+ },
35
+ )
36
+ return true
37
+ }
38
+ }
@@ -0,0 +1,30 @@
1
+ package io.github.maximbelov.applifecycle
2
+
3
+ import android.os.Process
4
+ import com.getcapacitor.Plugin
5
+ import com.getcapacitor.PluginCall
6
+ import com.getcapacitor.PluginMethod
7
+ import com.getcapacitor.annotation.CapacitorPlugin
8
+
9
+ @CapacitorPlugin(name = "AppLifecycle")
10
+ class AppLifecyclePlugin : Plugin() {
11
+
12
+ private val implementation by lazy {
13
+ AppLifecycle(
14
+ runOnMainThread = { task ->
15
+ val activity = getActivity()
16
+ if (activity != null) activity.runOnUiThread(task) else task.run()
17
+ },
18
+ finishTask = { getActivity()?.finishAffinity() },
19
+ killProcess = { Process.killProcess(Process.myPid()) },
20
+ )
21
+ }
22
+
23
+ @PluginMethod
24
+ fun forceClose(call: PluginCall) {
25
+ // Resolved before the exit is scheduled, as the Cordova plugin did. The process usually dies before the result reaches
26
+ // JavaScript, so callers must not depend on it.
27
+ call.resolve()
28
+ implementation.forceClose()
29
+ }
30
+ }
File without changes
package/dist/docs.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "api": {
3
+ "name": "AppLifecyclePlugin",
4
+ "slug": "applifecycleplugin",
5
+ "docs": "",
6
+ "tags": [],
7
+ "methods": [
8
+ {
9
+ "name": "forceClose",
10
+ "signature": "() => Promise<void>",
11
+ "parameters": [],
12
+ "returns": "Promise<void>",
13
+ "tags": [
14
+ {
15
+ "name": "since",
16
+ "text": "1.0.0"
17
+ }
18
+ ],
19
+ "docs": "Terminates the app process, so that the next launch starts from scratch.\n\nUse it when the app is in a state it cannot recover from in place, for\nexample a local database that has to be rebuilt, and the user should simply\nopen the app again. It is a clean exit, not a crash: nothing is reported to\na crash reporter.\n\nThe two platforms end the process differently, and the promise behaves\naccordingly:\n\n- **iOS**: the promise resolves, and the process exits with `exit(0)` about\n 0.3 seconds later, on the main thread.\n- **Android**: the activity task is finished with `finishAffinity()` and the\n process is killed straight away. The promise is resolved first, but the\n process usually goes down before the result reaches JavaScript, so do not\n wait on it for anything.\n\nCalling it again while an exit is already on its way resolves without\nscheduling a second one.\n\nOn the web it rejects with the `UNIMPLEMENTED` code.",
20
+ "complexTypes": [],
21
+ "slug": "forceclose"
22
+ }
23
+ ],
24
+ "properties": []
25
+ },
26
+ "interfaces": [],
27
+ "enums": [],
28
+ "typeAliases": [],
29
+ "pluginConfigs": []
30
+ }
@@ -0,0 +1,28 @@
1
+ export interface AppLifecyclePlugin {
2
+ /**
3
+ * Terminates the app process, so that the next launch starts from scratch.
4
+ *
5
+ * Use it when the app is in a state it cannot recover from in place, for
6
+ * example a local database that has to be rebuilt, and the user should simply
7
+ * open the app again. It is a clean exit, not a crash: nothing is reported to
8
+ * a crash reporter.
9
+ *
10
+ * The two platforms end the process differently, and the promise behaves
11
+ * accordingly:
12
+ *
13
+ * - **iOS**: the promise resolves, and the process exits with `exit(0)` about
14
+ * 0.3 seconds later, on the main thread.
15
+ * - **Android**: the activity task is finished with `finishAffinity()` and the
16
+ * process is killed straight away. The promise is resolved first, but the
17
+ * process usually goes down before the result reaches JavaScript, so do not
18
+ * wait on it for anything.
19
+ *
20
+ * Calling it again while an exit is already on its way resolves without
21
+ * scheduling a second one.
22
+ *
23
+ * On the web it rejects with the `UNIMPLEMENTED` code.
24
+ *
25
+ * @since 1.0.0
26
+ */
27
+ forceClose(): Promise<void>;
28
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=definitions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export interface AppLifecyclePlugin {\n /**\n * Terminates the app process, so that the next launch starts from scratch.\n *\n * Use it when the app is in a state it cannot recover from in place, for\n * example a local database that has to be rebuilt, and the user should simply\n * open the app again. It is a clean exit, not a crash: nothing is reported to\n * a crash reporter.\n *\n * The two platforms end the process differently, and the promise behaves\n * accordingly:\n *\n * - **iOS**: the promise resolves, and the process exits with `exit(0)` about\n * 0.3 seconds later, on the main thread.\n * - **Android**: the activity task is finished with `finishAffinity()` and the\n * process is killed straight away. The promise is resolved first, but the\n * process usually goes down before the result reaches JavaScript, so do not\n * wait on it for anything.\n *\n * Calling it again while an exit is already on its way resolves without\n * scheduling a second one.\n *\n * On the web it rejects with the `UNIMPLEMENTED` code.\n *\n * @since 1.0.0\n */\n forceClose(): Promise<void>;\n}\n"]}
@@ -0,0 +1,4 @@
1
+ import type { AppLifecyclePlugin } from './definitions';
2
+ declare const AppLifecycle: AppLifecyclePlugin;
3
+ export * from './definitions';
4
+ export { AppLifecycle };
@@ -0,0 +1,7 @@
1
+ import { registerPlugin } from '@capacitor/core';
2
+ const AppLifecycle = registerPlugin('AppLifecycle', {
3
+ web: () => import('./web').then((m) => new m.AppLifecycleWeb()),
4
+ });
5
+ export * from './definitions';
6
+ export { AppLifecycle };
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAIjD,MAAM,YAAY,GAAG,cAAc,CAAqB,cAAc,EAAE;IACtE,GAAG,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,eAAe,EAAE,CAAC;CAChE,CAAC,CAAC;AAEH,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,YAAY,EAAE,CAAC","sourcesContent":["import { registerPlugin } from '@capacitor/core';\n\nimport type { AppLifecyclePlugin } from './definitions';\n\nconst AppLifecycle = registerPlugin<AppLifecyclePlugin>('AppLifecycle', {\n web: () => import('./web').then((m) => new m.AppLifecycleWeb()),\n});\n\nexport * from './definitions';\nexport { AppLifecycle };\n"]}
@@ -0,0 +1,5 @@
1
+ import { WebPlugin } from '@capacitor/core';
2
+ import type { AppLifecyclePlugin } from './definitions';
3
+ export declare class AppLifecycleWeb extends WebPlugin implements AppLifecyclePlugin {
4
+ forceClose(): Promise<void>;
5
+ }
@@ -0,0 +1,7 @@
1
+ import { WebPlugin } from '@capacitor/core';
2
+ export class AppLifecycleWeb extends WebPlugin {
3
+ async forceClose() {
4
+ throw this.unimplemented('forceClose is not available on the web.');
5
+ }
6
+ }
7
+ //# sourceMappingURL=web.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web.js","sourceRoot":"","sources":["../../src/web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAI5C,MAAM,OAAO,eAAgB,SAAQ,SAAS;IAC5C,KAAK,CAAC,UAAU;QACd,MAAM,IAAI,CAAC,aAAa,CAAC,yCAAyC,CAAC,CAAC;IACtE,CAAC;CACF","sourcesContent":["import { WebPlugin } from '@capacitor/core';\n\nimport type { AppLifecyclePlugin } from './definitions';\n\nexport class AppLifecycleWeb extends WebPlugin implements AppLifecyclePlugin {\n async forceClose(): Promise<void> {\n throw this.unimplemented('forceClose is not available on the web.');\n }\n}\n"]}
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ var core = require('@capacitor/core');
4
+
5
+ const AppLifecycle = core.registerPlugin('AppLifecycle', {
6
+ web: () => Promise.resolve().then(function () { return web; }).then((m) => new m.AppLifecycleWeb()),
7
+ });
8
+
9
+ class AppLifecycleWeb extends core.WebPlugin {
10
+ async forceClose() {
11
+ throw this.unimplemented('forceClose is not available on the web.');
12
+ }
13
+ }
14
+
15
+ var web = /*#__PURE__*/Object.freeze({
16
+ __proto__: null,
17
+ AppLifecycleWeb: AppLifecycleWeb
18
+ });
19
+
20
+ exports.AppLifecycle = AppLifecycle;
21
+ //# sourceMappingURL=plugin.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.cjs.js","sources":["esm/index.js","esm/web.js"],"sourcesContent":["import { registerPlugin } from '@capacitor/core';\nconst AppLifecycle = registerPlugin('AppLifecycle', {\n web: () => import('./web').then((m) => new m.AppLifecycleWeb()),\n});\nexport * from './definitions';\nexport { AppLifecycle };\n//# sourceMappingURL=index.js.map","import { WebPlugin } from '@capacitor/core';\nexport class AppLifecycleWeb extends WebPlugin {\n async forceClose() {\n throw this.unimplemented('forceClose is not available on the web.');\n }\n}\n//# sourceMappingURL=web.js.map"],"names":["registerPlugin","WebPlugin"],"mappings":";;;;AACK,MAAC,YAAY,GAAGA,mBAAc,CAAC,cAAc,EAAE;AACpD,IAAI,GAAG,EAAE,MAAM,mDAAe,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,eAAe,EAAE,CAAC;AACnE,CAAC;;ACFM,MAAM,eAAe,SAASC,cAAS,CAAC;AAC/C,IAAI,MAAM,UAAU,GAAG;AACvB,QAAQ,MAAM,IAAI,CAAC,aAAa,CAAC,yCAAyC,CAAC;AAC3E,IAAI;AACJ;;;;;;;"}
package/dist/plugin.js ADDED
@@ -0,0 +1,24 @@
1
+ var capacitorAppLifecycle = (function (exports, core) {
2
+ 'use strict';
3
+
4
+ const AppLifecycle = core.registerPlugin('AppLifecycle', {
5
+ web: () => Promise.resolve().then(function () { return web; }).then((m) => new m.AppLifecycleWeb()),
6
+ });
7
+
8
+ class AppLifecycleWeb extends core.WebPlugin {
9
+ async forceClose() {
10
+ throw this.unimplemented('forceClose is not available on the web.');
11
+ }
12
+ }
13
+
14
+ var web = /*#__PURE__*/Object.freeze({
15
+ __proto__: null,
16
+ AppLifecycleWeb: AppLifecycleWeb
17
+ });
18
+
19
+ exports.AppLifecycle = AppLifecycle;
20
+
21
+ return exports;
22
+
23
+ })({}, capacitorExports);
24
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.js","sources":["esm/index.js","esm/web.js"],"sourcesContent":["import { registerPlugin } from '@capacitor/core';\nconst AppLifecycle = registerPlugin('AppLifecycle', {\n web: () => import('./web').then((m) => new m.AppLifecycleWeb()),\n});\nexport * from './definitions';\nexport { AppLifecycle };\n//# sourceMappingURL=index.js.map","import { WebPlugin } from '@capacitor/core';\nexport class AppLifecycleWeb extends WebPlugin {\n async forceClose() {\n throw this.unimplemented('forceClose is not available on the web.');\n }\n}\n//# sourceMappingURL=web.js.map"],"names":["registerPlugin","WebPlugin"],"mappings":";;;AACK,UAAC,YAAY,GAAGA,mBAAc,CAAC,cAAc,EAAE;IACpD,IAAI,GAAG,EAAE,MAAM,mDAAe,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,eAAe,EAAE,CAAC;IACnE,CAAC;;ICFM,MAAM,eAAe,SAASC,cAAS,CAAC;IAC/C,IAAI,MAAM,UAAU,GAAG;IACvB,QAAQ,MAAM,IAAI,CAAC,aAAa,CAAC,yCAAyC,CAAC;IAC3E,IAAI;IACJ;;;;;;;;;;;"}
@@ -0,0 +1,45 @@
1
+ import Foundation
2
+
3
+ /// Ends the app process with `exit(0)`, a short moment after it is asked to.
4
+ ///
5
+ /// The delay lets the resolved call travel back to the web view before the process goes, and keeps `exit` from racing a UIKit
6
+ /// transition that is still running. The scheduler and the exit are passed in so that the delay and the guard can be tested
7
+ /// without ending the test process; `AppLifecyclePlugin` uses the real ones.
8
+ @objc public class AppLifecycle: NSObject {
9
+ public static let defaultDelay: TimeInterval = 0.3
10
+
11
+ public typealias Scheduler = (_ delay: TimeInterval, _ work: @escaping () -> Void) -> Void
12
+
13
+ private let delay: TimeInterval
14
+ private let schedule: Scheduler
15
+ private let terminate: () -> Void
16
+ private let lock = NSLock()
17
+ private var closing = false
18
+
19
+ public init(
20
+ delay: TimeInterval = AppLifecycle.defaultDelay,
21
+ schedule: @escaping Scheduler = AppLifecycle.onMainQueue,
22
+ terminate: @escaping () -> Void = { exit(0) }
23
+ ) {
24
+ self.delay = delay
25
+ self.schedule = schedule
26
+ self.terminate = terminate
27
+ }
28
+
29
+ /// Schedules the exit. Returns false, and schedules nothing, when an exit is already scheduled.
30
+ @discardableResult
31
+ @objc public func forceClose() -> Bool {
32
+ lock.lock()
33
+ defer { lock.unlock() }
34
+ if closing {
35
+ return false
36
+ }
37
+ closing = true
38
+ schedule(delay, terminate)
39
+ return true
40
+ }
41
+
42
+ public static func onMainQueue(_ delay: TimeInterval, _ work: @escaping () -> Void) {
43
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
44
+ }
45
+ }
@@ -0,0 +1,18 @@
1
+ import Foundation
2
+ import Capacitor
3
+
4
+ @objc(AppLifecyclePlugin)
5
+ public class AppLifecyclePlugin: CAPPlugin, CAPBridgedPlugin {
6
+ public let identifier = "AppLifecyclePlugin"
7
+ public let jsName = "AppLifecycle"
8
+ public let pluginMethods: [CAPPluginMethod] = [
9
+ CAPPluginMethod(name: "forceClose", returnType: CAPPluginReturnPromise)
10
+ ]
11
+ private let implementation = AppLifecycle()
12
+
13
+ @objc func forceClose(_ call: CAPPluginCall) {
14
+ // Resolved first, so the promise settles in JavaScript before the process exits.
15
+ call.resolve()
16
+ implementation.forceClose()
17
+ }
18
+ }
@@ -0,0 +1,23 @@
1
+ import XCTest
2
+ import Capacitor
3
+ @testable import AppLifecyclePlugin
4
+
5
+ // The JavaScript reaches the plugin by jsName and the method by its name; a rename on either side fails only at run time on a
6
+ // phone, so it is pinned here.
7
+ final class AppLifecyclePluginTests: XCTestCase {
8
+ func testIsRegisteredUnderTheNameTheJavaScriptUses() {
9
+ let plugin = AppLifecyclePlugin()
10
+ XCTAssertEqual(plugin.jsName, "AppLifecycle")
11
+ }
12
+
13
+ func testExposesForceCloseAsAPromiseMethodAndNothingElse() {
14
+ let methods = AppLifecyclePlugin().pluginMethods
15
+ XCTAssertEqual(methods.map { $0.name }, ["forceClose"])
16
+ XCTAssertEqual(methods.first?.returnType, CAPPluginReturnPromise)
17
+ }
18
+
19
+ func testTheMethodNameHasAnObjectiveCSelector() {
20
+ // Capacitor calls the method through the selector "forceClose:"; a missing @objc compiles fine and fails on the phone.
21
+ XCTAssertTrue(AppLifecyclePlugin().responds(to: NSSelectorFromString("forceClose:")))
22
+ }
23
+ }
@@ -0,0 +1,68 @@
1
+ import XCTest
2
+ @testable import AppLifecyclePlugin
3
+
4
+ final class AppLifecycleTests: XCTestCase {
5
+ private var scheduled: [(delay: TimeInterval, work: () -> Void)] = []
6
+ private var exits = 0
7
+
8
+ private func lifecycle(delay: TimeInterval = AppLifecycle.defaultDelay) -> AppLifecycle {
9
+ AppLifecycle(
10
+ delay: delay,
11
+ schedule: { delay, work in self.scheduled.append((delay, work)) },
12
+ terminate: { self.exits += 1 }
13
+ )
14
+ }
15
+
16
+ override func setUp() {
17
+ super.setUp()
18
+ scheduled = []
19
+ exits = 0
20
+ }
21
+
22
+ func testNothingExitsBeforeTheScheduledWorkRuns() {
23
+ XCTAssertTrue(lifecycle().forceClose())
24
+ XCTAssertEqual(scheduled.count, 1)
25
+ XCTAssertEqual(exits, 0)
26
+ }
27
+
28
+ func testExitsWhenTheScheduledWorkRuns() {
29
+ lifecycle().forceClose()
30
+ scheduled.forEach { $0.work() }
31
+ XCTAssertEqual(exits, 1)
32
+ }
33
+
34
+ func testWaitsTheDefaultDelay() {
35
+ lifecycle().forceClose()
36
+ XCTAssertEqual(scheduled.first?.delay, 0.3)
37
+ }
38
+
39
+ func testASecondCallSchedulesNothing() {
40
+ let lifecycle = lifecycle()
41
+ XCTAssertTrue(lifecycle.forceClose())
42
+ XCTAssertFalse(lifecycle.forceClose())
43
+ scheduled.forEach { $0.work() }
44
+ XCTAssertEqual(scheduled.count, 1)
45
+ XCTAssertEqual(exits, 1)
46
+ }
47
+
48
+ func testConcurrentCallsScheduleOneExit() {
49
+ let lifecycle = lifecycle()
50
+ let results = UnsafeMutableBufferPointer<Bool>.allocate(capacity: 50)
51
+ defer { results.deallocate() }
52
+ DispatchQueue.concurrentPerform(iterations: 50) { index in
53
+ results[index] = lifecycle.forceClose()
54
+ }
55
+ XCTAssertEqual(results.filter { $0 }.count, 1)
56
+ }
57
+
58
+ func testTheMainQueueSchedulerRunsTheWorkOnTheMainThreadAfterTheDelay() {
59
+ let ran = expectation(description: "scheduled work ran")
60
+ let start = Date()
61
+ AppLifecycle.onMainQueue(0.05) {
62
+ XCTAssertTrue(Thread.isMainThread)
63
+ XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(start), 0.05)
64
+ ran.fulfill()
65
+ }
66
+ wait(for: [ran], timeout: 2)
67
+ }
68
+ }
package/package.json CHANGED
@@ -1,6 +1,98 @@
1
1
  {
2
2
  "name": "capacitor-app-lifecycle",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "Cleanly terminate the app process on iOS and Android from a Capacitor app",
5
+ "main": "dist/plugin.cjs.js",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/esm/index.d.ts",
8
+ "unpkg": "dist/plugin.js",
9
+ "files": [
10
+ "android/src/main/",
11
+ "android/build.gradle",
12
+ "dist/",
13
+ "ios/Sources",
14
+ "ios/Tests",
15
+ "Package.swift",
16
+ "CapacitorAppLifecycle.podspec"
17
+ ],
18
+ "author": "Maxim Belov",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/MaximBelov/capacitor-app-lifecycle.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/MaximBelov/capacitor-app-lifecycle/issues"
26
+ },
27
+ "keywords": [
28
+ "capacitor",
29
+ "plugin",
30
+ "native",
31
+ "exit",
32
+ "force close",
33
+ "terminate",
34
+ "app lifecycle"
35
+ ],
36
+ "scripts": {
37
+ "verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
38
+ "verify:ios": "xcodebuild build -scheme CapacitorAppLifecycle -destination generic/platform=iOS -derivedDataPath build/ios-device",
39
+ "verify:android": "cd android && ./gradlew clean build test",
40
+ "verify:web": "npm run build",
41
+ "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
42
+ "fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format",
43
+ "eslint": "eslint . --ext ts",
44
+ "prettier": "prettier \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java",
45
+ "swiftlint": "node-swiftlint",
46
+ "docgen": "docgen --api AppLifecyclePlugin --output-readme README.md --output-json dist/docs.json",
47
+ "build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
48
+ "clean": "rimraf ./dist",
49
+ "watch": "tsc --watch",
50
+ "prepublishOnly": "npm run build",
51
+ "test": "npm run test:unit",
52
+ "test:unit": "vitest run",
53
+ "test:unit:coverage": "vitest run --coverage",
54
+ "test:parity": "node --test test/unit/platform-parity.test.mjs",
55
+ "test:android": "cd android && ./gradlew test",
56
+ "test:ios": "./scripts/test-ios.sh",
57
+ "example:install": "cd example-app && npm install --no-audit --no-fund",
58
+ "example:android:build": "npm run example:install && cd example-app && npm run build && npx cap sync android && cd android && ./gradlew assembleDebug",
59
+ "example:ios:build": "npm run example:install && cd example-app && npm run build && npx cap sync ios && xcodebuild build -project ios/App/App.xcodeproj -scheme App -destination generic/platform=iOS\\ Simulator -derivedDataPath ../build/example-ios CODE_SIGNING_ALLOWED=NO"
60
+ },
61
+ "devDependencies": {
62
+ "@capacitor/android": "^8.0.0",
63
+ "@capacitor/core": "^8.0.0",
64
+ "@capacitor/docgen": "^0.3.1",
65
+ "@capacitor/ios": "^8.0.0",
66
+ "@ionic/eslint-config": "^0.4.0",
67
+ "@ionic/prettier-config": "^4.0.0",
68
+ "@ionic/swiftlint-config": "^2.0.0",
69
+ "@vitest/coverage-v8": "^4.1.12",
70
+ "eslint": "^8.57.1",
71
+ "prettier": "^3.6.2",
72
+ "prettier-plugin-java": "^2.7.7",
73
+ "rimraf": "^6.1.0",
74
+ "rollup": "^4.53.2",
75
+ "swiftlint": "^2.0.0",
76
+ "typescript": "^5.9.3",
77
+ "vitest": "^4.1.12"
78
+ },
79
+ "peerDependencies": {
80
+ "@capacitor/core": ">=8.0.0"
81
+ },
82
+ "prettier": "@ionic/prettier-config",
83
+ "swiftlint": "@ionic/swiftlint-config",
84
+ "eslintConfig": {
85
+ "extends": "@ionic/eslint-config/recommended"
86
+ },
87
+ "capacitor": {
88
+ "ios": {
89
+ "src": "ios"
90
+ },
91
+ "android": {
92
+ "src": "android"
93
+ }
94
+ },
95
+ "engines": {
96
+ "node": ">=20"
97
+ }
98
+ }