@openmdm/plugin-kiosk 0.2.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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +129 -0
- package/dist/index.js +396 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/index.ts +685 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-present OpenMDM Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { MDMPlugin } from '@openmdm/core';
|
|
2
|
+
export { MDMPlugin } from '@openmdm/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenMDM Kiosk Mode Plugin
|
|
6
|
+
*
|
|
7
|
+
* Provides kiosk/lockdown mode functionality for Android devices.
|
|
8
|
+
* Supports single-app mode, multi-app kiosk, and screen pinning.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { createMDM } from '@openmdm/core';
|
|
13
|
+
* import { kioskPlugin } from '@openmdm/plugin-kiosk';
|
|
14
|
+
*
|
|
15
|
+
* const mdm = createMDM({
|
|
16
|
+
* database: drizzleAdapter(db),
|
|
17
|
+
* plugins: [
|
|
18
|
+
* kioskPlugin({
|
|
19
|
+
* defaultExitPassword: 'admin123',
|
|
20
|
+
* allowRemoteExit: true,
|
|
21
|
+
* }),
|
|
22
|
+
* ],
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
interface KioskPluginOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Default password for exiting kiosk mode
|
|
30
|
+
* Can be overridden per-policy
|
|
31
|
+
*/
|
|
32
|
+
defaultExitPassword?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Allow remote exit command from server
|
|
35
|
+
*/
|
|
36
|
+
allowRemoteExit?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Auto-lock device when kiosk app crashes
|
|
39
|
+
*/
|
|
40
|
+
lockOnCrash?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Restart kiosk app if it closes
|
|
43
|
+
*/
|
|
44
|
+
autoRestart?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Auto-restart delay in milliseconds
|
|
47
|
+
*/
|
|
48
|
+
autoRestartDelay?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Maximum exit password attempts before lock
|
|
51
|
+
*/
|
|
52
|
+
maxExitAttempts?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Lockout duration in minutes after max attempts
|
|
55
|
+
*/
|
|
56
|
+
lockoutDuration?: number;
|
|
57
|
+
}
|
|
58
|
+
interface KioskSettings {
|
|
59
|
+
/** Enable kiosk mode */
|
|
60
|
+
enabled: boolean;
|
|
61
|
+
/** Kiosk mode type */
|
|
62
|
+
mode: 'single-app' | 'multi-app' | 'screen-pin';
|
|
63
|
+
/** Main/launcher app package name */
|
|
64
|
+
mainApp: string;
|
|
65
|
+
/** Allowed apps in multi-app mode */
|
|
66
|
+
allowedApps?: string[];
|
|
67
|
+
/** Password to exit kiosk mode (device-side) */
|
|
68
|
+
exitPassword?: string;
|
|
69
|
+
/** Secret gesture to trigger exit (e.g., '5-tap-corners') */
|
|
70
|
+
exitGesture?: string;
|
|
71
|
+
/** Lock status bar */
|
|
72
|
+
lockStatusBar?: boolean;
|
|
73
|
+
/** Lock navigation bar */
|
|
74
|
+
lockNavigationBar?: boolean;
|
|
75
|
+
/** Disable home button */
|
|
76
|
+
disableHomeButton?: boolean;
|
|
77
|
+
/** Disable recent apps button */
|
|
78
|
+
disableRecentApps?: boolean;
|
|
79
|
+
/** Disable power button (soft) */
|
|
80
|
+
disablePowerButton?: boolean;
|
|
81
|
+
/** Disable volume buttons */
|
|
82
|
+
disableVolumeButtons?: boolean;
|
|
83
|
+
/** Lock screen orientation */
|
|
84
|
+
lockOrientation?: 'portrait' | 'landscape' | 'auto';
|
|
85
|
+
/** Disable notifications */
|
|
86
|
+
disableNotifications?: boolean;
|
|
87
|
+
/** Allowed notification packages */
|
|
88
|
+
allowedNotifications?: string[];
|
|
89
|
+
/** Keep screen on */
|
|
90
|
+
keepScreenOn?: boolean;
|
|
91
|
+
/** Screen brightness (0-255, or 'auto') */
|
|
92
|
+
screenBrightness?: number | 'auto';
|
|
93
|
+
/** Auto-launch on boot */
|
|
94
|
+
launchOnBoot?: boolean;
|
|
95
|
+
/** Auto-restart on crash */
|
|
96
|
+
restartOnCrash?: boolean;
|
|
97
|
+
/** Restart delay in ms */
|
|
98
|
+
restartDelay?: number;
|
|
99
|
+
/** Custom wallpaper URL */
|
|
100
|
+
wallpaperUrl?: string;
|
|
101
|
+
/** Hide system UI elements */
|
|
102
|
+
immersiveMode?: boolean;
|
|
103
|
+
/** Allow status bar pull-down for specific items */
|
|
104
|
+
allowedStatusBarItems?: ('wifi' | 'bluetooth' | 'airplane' | 'battery')[];
|
|
105
|
+
/** Show clock/time */
|
|
106
|
+
showClock?: boolean;
|
|
107
|
+
/** Show battery indicator */
|
|
108
|
+
showBattery?: boolean;
|
|
109
|
+
/** Custom exit password per device (encrypted) */
|
|
110
|
+
deviceExitPasswords?: Record<string, string>;
|
|
111
|
+
}
|
|
112
|
+
interface KioskState {
|
|
113
|
+
deviceId: string;
|
|
114
|
+
enabled: boolean;
|
|
115
|
+
mode: 'single-app' | 'multi-app' | 'screen-pin';
|
|
116
|
+
mainApp: string;
|
|
117
|
+
activeApp?: string;
|
|
118
|
+
lockedSince?: Date;
|
|
119
|
+
exitAttempts: number;
|
|
120
|
+
lastExitAttempt?: Date;
|
|
121
|
+
lockedOut: boolean;
|
|
122
|
+
lockoutUntil?: Date;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Create kiosk mode plugin
|
|
126
|
+
*/
|
|
127
|
+
declare function kioskPlugin(options?: KioskPluginOptions): MDMPlugin;
|
|
128
|
+
|
|
129
|
+
export { type KioskPluginOptions, type KioskSettings, type KioskState, kioskPlugin };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
function kioskPlugin(options = {}) {
|
|
3
|
+
const {
|
|
4
|
+
defaultExitPassword = "admin",
|
|
5
|
+
allowRemoteExit = true,
|
|
6
|
+
lockOnCrash = true,
|
|
7
|
+
autoRestart = true,
|
|
8
|
+
autoRestartDelay = 1e3,
|
|
9
|
+
maxExitAttempts = 5,
|
|
10
|
+
lockoutDuration = 15
|
|
11
|
+
} = options;
|
|
12
|
+
let mdm;
|
|
13
|
+
const kioskStates = /* @__PURE__ */ new Map();
|
|
14
|
+
function getKioskSettings(policy) {
|
|
15
|
+
const settings = policy.settings;
|
|
16
|
+
if (!settings.kioskMode || !settings.mainApp) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
enabled: settings.kioskMode,
|
|
21
|
+
mode: "single-app",
|
|
22
|
+
// Default mode
|
|
23
|
+
mainApp: settings.mainApp,
|
|
24
|
+
allowedApps: settings.allowedApps,
|
|
25
|
+
exitPassword: settings.kioskExitPassword || defaultExitPassword,
|
|
26
|
+
lockStatusBar: settings.lockStatusBar ?? true,
|
|
27
|
+
lockNavigationBar: settings.lockNavigationBar ?? true,
|
|
28
|
+
disableHomeButton: true,
|
|
29
|
+
disableRecentApps: true,
|
|
30
|
+
disablePowerButton: settings.lockPowerButton ?? false,
|
|
31
|
+
keepScreenOn: true,
|
|
32
|
+
launchOnBoot: true,
|
|
33
|
+
restartOnCrash: autoRestart,
|
|
34
|
+
restartDelay: autoRestartDelay,
|
|
35
|
+
immersiveMode: true,
|
|
36
|
+
showClock: true,
|
|
37
|
+
showBattery: true,
|
|
38
|
+
...settings.custom?.kiosk
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function getKioskState(deviceId) {
|
|
42
|
+
return kioskStates.get(deviceId);
|
|
43
|
+
}
|
|
44
|
+
function updateKioskState(deviceId, updates) {
|
|
45
|
+
const current = kioskStates.get(deviceId) || {
|
|
46
|
+
deviceId,
|
|
47
|
+
enabled: false,
|
|
48
|
+
mode: "single-app",
|
|
49
|
+
mainApp: "",
|
|
50
|
+
exitAttempts: 0,
|
|
51
|
+
lockedOut: false
|
|
52
|
+
};
|
|
53
|
+
const updated = { ...current, ...updates };
|
|
54
|
+
kioskStates.set(deviceId, updated);
|
|
55
|
+
return updated;
|
|
56
|
+
}
|
|
57
|
+
async function handleExitKiosk(device, command) {
|
|
58
|
+
if (!allowRemoteExit) {
|
|
59
|
+
return {
|
|
60
|
+
success: false,
|
|
61
|
+
message: "Remote kiosk exit is disabled"
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const state = getKioskState(device.id);
|
|
65
|
+
if (!state?.enabled) {
|
|
66
|
+
return {
|
|
67
|
+
success: true,
|
|
68
|
+
message: "Device is not in kiosk mode"
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
updateKioskState(device.id, {
|
|
72
|
+
enabled: false,
|
|
73
|
+
exitAttempts: 0,
|
|
74
|
+
lockedOut: false
|
|
75
|
+
});
|
|
76
|
+
await mdm.emit("custom", {
|
|
77
|
+
type: "kiosk.exited",
|
|
78
|
+
deviceId: device.id,
|
|
79
|
+
method: "remote",
|
|
80
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
success: true,
|
|
84
|
+
message: "Kiosk mode exit command sent"
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async function handleEnterKiosk(device, command) {
|
|
88
|
+
const payload = command.payload;
|
|
89
|
+
let kioskSettings = null;
|
|
90
|
+
if (device.policyId) {
|
|
91
|
+
const policy = await mdm.policies.get(device.policyId);
|
|
92
|
+
if (policy) {
|
|
93
|
+
kioskSettings = getKioskSettings(policy);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const mainApp = payload?.app || kioskSettings?.mainApp;
|
|
97
|
+
if (!mainApp) {
|
|
98
|
+
return {
|
|
99
|
+
success: false,
|
|
100
|
+
message: "No main app specified for kiosk mode"
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
updateKioskState(device.id, {
|
|
104
|
+
enabled: true,
|
|
105
|
+
mode: kioskSettings?.mode || "single-app",
|
|
106
|
+
mainApp,
|
|
107
|
+
lockedSince: /* @__PURE__ */ new Date(),
|
|
108
|
+
exitAttempts: 0,
|
|
109
|
+
lockedOut: false
|
|
110
|
+
});
|
|
111
|
+
await mdm.emit("custom", {
|
|
112
|
+
type: "kiosk.entered",
|
|
113
|
+
deviceId: device.id,
|
|
114
|
+
mainApp,
|
|
115
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
success: true,
|
|
119
|
+
message: `Kiosk mode activated with ${mainApp}`,
|
|
120
|
+
data: { mainApp }
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function validateKioskPolicy(settings) {
|
|
124
|
+
const errors = [];
|
|
125
|
+
if (settings.kioskMode) {
|
|
126
|
+
if (!settings.mainApp) {
|
|
127
|
+
errors.push("Kiosk mode requires a main app package name");
|
|
128
|
+
}
|
|
129
|
+
if (settings.allowedApps && settings.allowedApps.length > 0) {
|
|
130
|
+
if (!settings.allowedApps.includes(settings.mainApp)) {
|
|
131
|
+
errors.push("Main app must be included in allowed apps list");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const custom = settings.custom?.kiosk;
|
|
135
|
+
if (custom?.mode === "multi-app" && !settings.allowedApps?.length) {
|
|
136
|
+
errors.push("Multi-app kiosk mode requires allowed apps list");
|
|
137
|
+
}
|
|
138
|
+
if (custom?.screenBrightness !== void 0 && custom.screenBrightness !== "auto") {
|
|
139
|
+
if (typeof custom.screenBrightness === "number") {
|
|
140
|
+
if (custom.screenBrightness < 0 || custom.screenBrightness > 255) {
|
|
141
|
+
errors.push('Screen brightness must be between 0-255 or "auto"');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
valid: errors.length === 0,
|
|
148
|
+
errors: errors.length > 0 ? errors : void 0
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const routes = [
|
|
152
|
+
// Get kiosk status for a device
|
|
153
|
+
{
|
|
154
|
+
method: "GET",
|
|
155
|
+
path: "/kiosk/:deviceId/status",
|
|
156
|
+
auth: true,
|
|
157
|
+
admin: true,
|
|
158
|
+
handler: async (context) => {
|
|
159
|
+
const { deviceId } = context.req.param();
|
|
160
|
+
const state = getKioskState(deviceId);
|
|
161
|
+
if (!state) {
|
|
162
|
+
return context.json({ enabled: false });
|
|
163
|
+
}
|
|
164
|
+
return context.json({
|
|
165
|
+
...state,
|
|
166
|
+
lockedSince: state.lockedSince?.toISOString(),
|
|
167
|
+
lastExitAttempt: state.lastExitAttempt?.toISOString(),
|
|
168
|
+
lockoutUntil: state.lockoutUntil?.toISOString()
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
// Enter kiosk mode
|
|
173
|
+
{
|
|
174
|
+
method: "POST",
|
|
175
|
+
path: "/kiosk/:deviceId/enter",
|
|
176
|
+
auth: true,
|
|
177
|
+
admin: true,
|
|
178
|
+
handler: async (context) => {
|
|
179
|
+
const { deviceId } = context.req.param();
|
|
180
|
+
const body = await context.req.json();
|
|
181
|
+
const command = await mdm.commands.send({
|
|
182
|
+
deviceId,
|
|
183
|
+
type: "enterKiosk",
|
|
184
|
+
payload: { app: body.app }
|
|
185
|
+
});
|
|
186
|
+
return context.json({ success: true, commandId: command.id });
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
// Exit kiosk mode
|
|
190
|
+
{
|
|
191
|
+
method: "POST",
|
|
192
|
+
path: "/kiosk/:deviceId/exit",
|
|
193
|
+
auth: true,
|
|
194
|
+
admin: true,
|
|
195
|
+
handler: async (context) => {
|
|
196
|
+
const { deviceId } = context.req.param();
|
|
197
|
+
const command = await mdm.commands.send({
|
|
198
|
+
deviceId,
|
|
199
|
+
type: "exitKiosk"
|
|
200
|
+
});
|
|
201
|
+
return context.json({ success: true, commandId: command.id });
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
// Get all devices in kiosk mode
|
|
205
|
+
{
|
|
206
|
+
method: "GET",
|
|
207
|
+
path: "/kiosk/devices",
|
|
208
|
+
auth: true,
|
|
209
|
+
admin: true,
|
|
210
|
+
handler: async (context) => {
|
|
211
|
+
const kioskDevices = Array.from(kioskStates.entries()).filter(([_, state]) => state.enabled).map(([_, state]) => ({
|
|
212
|
+
...state,
|
|
213
|
+
lockedSince: state.lockedSince?.toISOString()
|
|
214
|
+
}));
|
|
215
|
+
return context.json({ devices: kioskDevices });
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
// Report exit attempt from device
|
|
219
|
+
{
|
|
220
|
+
method: "POST",
|
|
221
|
+
path: "/kiosk/exit-attempt",
|
|
222
|
+
auth: true,
|
|
223
|
+
handler: async (context) => {
|
|
224
|
+
const body = await context.req.json();
|
|
225
|
+
const { deviceId, success, password } = body;
|
|
226
|
+
const state = getKioskState(deviceId);
|
|
227
|
+
if (!state?.enabled) {
|
|
228
|
+
return context.json({ error: "Device not in kiosk mode" }, 400);
|
|
229
|
+
}
|
|
230
|
+
if (state.lockedOut && state.lockoutUntil) {
|
|
231
|
+
if (/* @__PURE__ */ new Date() < state.lockoutUntil) {
|
|
232
|
+
return context.json({
|
|
233
|
+
error: "Exit attempts locked",
|
|
234
|
+
lockoutUntil: state.lockoutUntil.toISOString()
|
|
235
|
+
}, 403);
|
|
236
|
+
} else {
|
|
237
|
+
updateKioskState(deviceId, {
|
|
238
|
+
lockedOut: false,
|
|
239
|
+
lockoutUntil: void 0,
|
|
240
|
+
exitAttempts: 0
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (success) {
|
|
245
|
+
updateKioskState(deviceId, {
|
|
246
|
+
enabled: false,
|
|
247
|
+
exitAttempts: 0
|
|
248
|
+
});
|
|
249
|
+
await mdm.emit("custom", {
|
|
250
|
+
type: "kiosk.exited",
|
|
251
|
+
deviceId,
|
|
252
|
+
method: "local",
|
|
253
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
254
|
+
});
|
|
255
|
+
return context.json({ success: true });
|
|
256
|
+
} else {
|
|
257
|
+
const newAttempts = state.exitAttempts + 1;
|
|
258
|
+
if (newAttempts >= maxExitAttempts) {
|
|
259
|
+
const lockoutUntil = new Date(
|
|
260
|
+
Date.now() + lockoutDuration * 60 * 1e3
|
|
261
|
+
);
|
|
262
|
+
updateKioskState(deviceId, {
|
|
263
|
+
exitAttempts: newAttempts,
|
|
264
|
+
lastExitAttempt: /* @__PURE__ */ new Date(),
|
|
265
|
+
lockedOut: true,
|
|
266
|
+
lockoutUntil
|
|
267
|
+
});
|
|
268
|
+
await mdm.emit("custom", {
|
|
269
|
+
type: "kiosk.lockout",
|
|
270
|
+
deviceId,
|
|
271
|
+
attempts: newAttempts,
|
|
272
|
+
lockoutUntil: lockoutUntil.toISOString()
|
|
273
|
+
});
|
|
274
|
+
return context.json({
|
|
275
|
+
error: "Max attempts exceeded",
|
|
276
|
+
lockedOut: true,
|
|
277
|
+
lockoutUntil: lockoutUntil.toISOString()
|
|
278
|
+
}, 403);
|
|
279
|
+
} else {
|
|
280
|
+
updateKioskState(deviceId, {
|
|
281
|
+
exitAttempts: newAttempts,
|
|
282
|
+
lastExitAttempt: /* @__PURE__ */ new Date()
|
|
283
|
+
});
|
|
284
|
+
return context.json({
|
|
285
|
+
error: "Invalid password",
|
|
286
|
+
attemptsRemaining: maxExitAttempts - newAttempts
|
|
287
|
+
}, 401);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
// Report kiosk app crash from device
|
|
293
|
+
{
|
|
294
|
+
method: "POST",
|
|
295
|
+
path: "/kiosk/app-crash",
|
|
296
|
+
auth: true,
|
|
297
|
+
handler: async (context) => {
|
|
298
|
+
const body = await context.req.json();
|
|
299
|
+
const { deviceId, packageName, error } = body;
|
|
300
|
+
await mdm.emit("custom", {
|
|
301
|
+
type: "kiosk.appCrash",
|
|
302
|
+
deviceId,
|
|
303
|
+
packageName,
|
|
304
|
+
error,
|
|
305
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
306
|
+
});
|
|
307
|
+
const state = getKioskState(deviceId);
|
|
308
|
+
const shouldRestart = state?.enabled && autoRestart;
|
|
309
|
+
return context.json({
|
|
310
|
+
restart: shouldRestart,
|
|
311
|
+
restartDelay: autoRestartDelay,
|
|
312
|
+
lockDevice: lockOnCrash
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
];
|
|
317
|
+
return {
|
|
318
|
+
name: "kiosk",
|
|
319
|
+
version: "1.0.0",
|
|
320
|
+
async onInit(instance) {
|
|
321
|
+
mdm = instance;
|
|
322
|
+
console.log("[OpenMDM Kiosk] Plugin initialized");
|
|
323
|
+
},
|
|
324
|
+
async onDestroy() {
|
|
325
|
+
kioskStates.clear();
|
|
326
|
+
console.log("[OpenMDM Kiosk] Plugin destroyed");
|
|
327
|
+
},
|
|
328
|
+
routes,
|
|
329
|
+
async onDeviceEnrolled(device) {
|
|
330
|
+
if (device.policyId) {
|
|
331
|
+
const policy = await mdm.policies.get(device.policyId);
|
|
332
|
+
if (policy) {
|
|
333
|
+
const kioskSettings = getKioskSettings(policy);
|
|
334
|
+
if (kioskSettings?.enabled) {
|
|
335
|
+
updateKioskState(device.id, {
|
|
336
|
+
enabled: true,
|
|
337
|
+
mode: kioskSettings.mode,
|
|
338
|
+
mainApp: kioskSettings.mainApp,
|
|
339
|
+
lockedSince: /* @__PURE__ */ new Date()
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
async onHeartbeat(device, heartbeat) {
|
|
346
|
+
const state = getKioskState(device.id);
|
|
347
|
+
if (state?.enabled && heartbeat.runningApps) {
|
|
348
|
+
const isKioskAppRunning = heartbeat.runningApps.includes(state.mainApp);
|
|
349
|
+
if (!isKioskAppRunning && autoRestart) {
|
|
350
|
+
await mdm.commands.send({
|
|
351
|
+
deviceId: device.id,
|
|
352
|
+
type: "runApp",
|
|
353
|
+
payload: { packageName: state.mainApp }
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
policySchema: {
|
|
359
|
+
kioskMode: { type: "boolean", description: "Enable kiosk mode" },
|
|
360
|
+
mainApp: {
|
|
361
|
+
type: "string",
|
|
362
|
+
description: "Main kiosk app package name"
|
|
363
|
+
},
|
|
364
|
+
allowedApps: {
|
|
365
|
+
type: "array",
|
|
366
|
+
items: { type: "string" },
|
|
367
|
+
description: "Allowed apps in kiosk mode"
|
|
368
|
+
},
|
|
369
|
+
kioskExitPassword: {
|
|
370
|
+
type: "string",
|
|
371
|
+
description: "Password to exit kiosk mode"
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
validatePolicy: async (settings) => {
|
|
375
|
+
return validateKioskPolicy(settings);
|
|
376
|
+
},
|
|
377
|
+
commandTypes: ["enterKiosk", "exitKiosk"],
|
|
378
|
+
executeCommand: async (device, command) => {
|
|
379
|
+
switch (command.type) {
|
|
380
|
+
case "enterKiosk":
|
|
381
|
+
return handleEnterKiosk(device, command);
|
|
382
|
+
case "exitKiosk":
|
|
383
|
+
return handleExitKiosk(device, command);
|
|
384
|
+
default:
|
|
385
|
+
return {
|
|
386
|
+
success: false,
|
|
387
|
+
message: `Unknown command type: ${command.type}`
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
export {
|
|
394
|
+
kioskPlugin
|
|
395
|
+
};
|
|
396
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * OpenMDM Kiosk Mode Plugin\n *\n * Provides kiosk/lockdown mode functionality for Android devices.\n * Supports single-app mode, multi-app kiosk, and screen pinning.\n *\n * @example\n * ```typescript\n * import { createMDM } from '@openmdm/core';\n * import { kioskPlugin } from '@openmdm/plugin-kiosk';\n *\n * const mdm = createMDM({\n * database: drizzleAdapter(db),\n * plugins: [\n * kioskPlugin({\n * defaultExitPassword: 'admin123',\n * allowRemoteExit: true,\n * }),\n * ],\n * });\n * ```\n */\n\nimport type {\n MDMPlugin,\n MDMInstance,\n Device,\n Policy,\n PolicySettings,\n Command,\n CommandResult,\n Heartbeat,\n PluginRoute,\n} from '@openmdm/core';\n\n// ============================================\n// Kiosk Types\n// ============================================\n\nexport interface KioskPluginOptions {\n /**\n * Default password for exiting kiosk mode\n * Can be overridden per-policy\n */\n defaultExitPassword?: string;\n\n /**\n * Allow remote exit command from server\n */\n allowRemoteExit?: boolean;\n\n /**\n * Auto-lock device when kiosk app crashes\n */\n lockOnCrash?: boolean;\n\n /**\n * Restart kiosk app if it closes\n */\n autoRestart?: boolean;\n\n /**\n * Auto-restart delay in milliseconds\n */\n autoRestartDelay?: number;\n\n /**\n * Maximum exit password attempts before lock\n */\n maxExitAttempts?: number;\n\n /**\n * Lockout duration in minutes after max attempts\n */\n lockoutDuration?: number;\n}\n\nexport interface KioskSettings {\n /** Enable kiosk mode */\n enabled: boolean;\n\n /** Kiosk mode type */\n mode: 'single-app' | 'multi-app' | 'screen-pin';\n\n /** Main/launcher app package name */\n mainApp: string;\n\n /** Allowed apps in multi-app mode */\n allowedApps?: string[];\n\n /** Password to exit kiosk mode (device-side) */\n exitPassword?: string;\n\n /** Secret gesture to trigger exit (e.g., '5-tap-corners') */\n exitGesture?: string;\n\n /** Lock status bar */\n lockStatusBar?: boolean;\n\n /** Lock navigation bar */\n lockNavigationBar?: boolean;\n\n /** Disable home button */\n disableHomeButton?: boolean;\n\n /** Disable recent apps button */\n disableRecentApps?: boolean;\n\n /** Disable power button (soft) */\n disablePowerButton?: boolean;\n\n /** Disable volume buttons */\n disableVolumeButtons?: boolean;\n\n /** Lock screen orientation */\n lockOrientation?: 'portrait' | 'landscape' | 'auto';\n\n /** Disable notifications */\n disableNotifications?: boolean;\n\n /** Allowed notification packages */\n allowedNotifications?: string[];\n\n /** Keep screen on */\n keepScreenOn?: boolean;\n\n /** Screen brightness (0-255, or 'auto') */\n screenBrightness?: number | 'auto';\n\n /** Auto-launch on boot */\n launchOnBoot?: boolean;\n\n /** Auto-restart on crash */\n restartOnCrash?: boolean;\n\n /** Restart delay in ms */\n restartDelay?: number;\n\n /** Custom wallpaper URL */\n wallpaperUrl?: string;\n\n /** Hide system UI elements */\n immersiveMode?: boolean;\n\n /** Allow status bar pull-down for specific items */\n allowedStatusBarItems?: ('wifi' | 'bluetooth' | 'airplane' | 'battery')[];\n\n /** Show clock/time */\n showClock?: boolean;\n\n /** Show battery indicator */\n showBattery?: boolean;\n\n /** Custom exit password per device (encrypted) */\n deviceExitPasswords?: Record<string, string>;\n}\n\nexport interface KioskState {\n deviceId: string;\n enabled: boolean;\n mode: 'single-app' | 'multi-app' | 'screen-pin';\n mainApp: string;\n activeApp?: string;\n lockedSince?: Date;\n exitAttempts: number;\n lastExitAttempt?: Date;\n lockedOut: boolean;\n lockoutUntil?: Date;\n}\n\n// ============================================\n// Kiosk Plugin Implementation\n// ============================================\n\n/**\n * Create kiosk mode plugin\n */\nexport function kioskPlugin(options: KioskPluginOptions = {}): MDMPlugin {\n const {\n defaultExitPassword = 'admin',\n allowRemoteExit = true,\n lockOnCrash = true,\n autoRestart = true,\n autoRestartDelay = 1000,\n maxExitAttempts = 5,\n lockoutDuration = 15,\n } = options;\n\n let mdm: MDMInstance;\n\n // In-memory kiosk state (should be persisted to DB in production)\n const kioskStates = new Map<string, KioskState>();\n\n /**\n * Get kiosk settings from policy\n */\n function getKioskSettings(policy: Policy): KioskSettings | null {\n const settings = policy.settings;\n\n if (!settings.kioskMode || !settings.mainApp) {\n return null;\n }\n\n return {\n enabled: settings.kioskMode,\n mode: 'single-app', // Default mode\n mainApp: settings.mainApp,\n allowedApps: settings.allowedApps,\n exitPassword: settings.kioskExitPassword || defaultExitPassword,\n lockStatusBar: settings.lockStatusBar ?? true,\n lockNavigationBar: settings.lockNavigationBar ?? true,\n disableHomeButton: true,\n disableRecentApps: true,\n disablePowerButton: settings.lockPowerButton ?? false,\n keepScreenOn: true,\n launchOnBoot: true,\n restartOnCrash: autoRestart,\n restartDelay: autoRestartDelay,\n immersiveMode: true,\n showClock: true,\n showBattery: true,\n ...(settings.custom?.kiosk as Partial<KioskSettings>),\n };\n }\n\n /**\n * Get or create kiosk state for device\n */\n function getKioskState(deviceId: string): KioskState | undefined {\n return kioskStates.get(deviceId);\n }\n\n /**\n * Update kiosk state\n */\n function updateKioskState(\n deviceId: string,\n updates: Partial<KioskState>\n ): KioskState {\n const current = kioskStates.get(deviceId) || {\n deviceId,\n enabled: false,\n mode: 'single-app' as const,\n mainApp: '',\n exitAttempts: 0,\n lockedOut: false,\n };\n\n const updated = { ...current, ...updates };\n kioskStates.set(deviceId, updated);\n return updated;\n }\n\n /**\n * Handle exit kiosk command\n */\n async function handleExitKiosk(\n device: Device,\n command: Command\n ): Promise<CommandResult> {\n if (!allowRemoteExit) {\n return {\n success: false,\n message: 'Remote kiosk exit is disabled',\n };\n }\n\n const state = getKioskState(device.id);\n if (!state?.enabled) {\n return {\n success: true,\n message: 'Device is not in kiosk mode',\n };\n }\n\n // Update state\n updateKioskState(device.id, {\n enabled: false,\n exitAttempts: 0,\n lockedOut: false,\n });\n\n // Emit event\n await mdm.emit('custom', {\n type: 'kiosk.exited',\n deviceId: device.id,\n method: 'remote',\n timestamp: new Date().toISOString(),\n });\n\n return {\n success: true,\n message: 'Kiosk mode exit command sent',\n };\n }\n\n /**\n * Handle enter kiosk command\n */\n async function handleEnterKiosk(\n device: Device,\n command: Command\n ): Promise<CommandResult> {\n const payload = command.payload as { app?: string } | undefined;\n\n // Get policy kiosk settings\n let kioskSettings: KioskSettings | null = null;\n if (device.policyId) {\n const policy = await mdm.policies.get(device.policyId);\n if (policy) {\n kioskSettings = getKioskSettings(policy);\n }\n }\n\n const mainApp = payload?.app || kioskSettings?.mainApp;\n if (!mainApp) {\n return {\n success: false,\n message: 'No main app specified for kiosk mode',\n };\n }\n\n // Update state\n updateKioskState(device.id, {\n enabled: true,\n mode: kioskSettings?.mode || 'single-app',\n mainApp,\n lockedSince: new Date(),\n exitAttempts: 0,\n lockedOut: false,\n });\n\n // Emit event\n await mdm.emit('custom', {\n type: 'kiosk.entered',\n deviceId: device.id,\n mainApp,\n timestamp: new Date().toISOString(),\n });\n\n return {\n success: true,\n message: `Kiosk mode activated with ${mainApp}`,\n data: { mainApp },\n };\n }\n\n /**\n * Validate kiosk policy settings\n */\n function validateKioskPolicy(\n settings: PolicySettings\n ): { valid: boolean; errors?: string[] } {\n const errors: string[] = [];\n\n if (settings.kioskMode) {\n if (!settings.mainApp) {\n errors.push('Kiosk mode requires a main app package name');\n }\n\n if (settings.allowedApps && settings.allowedApps.length > 0) {\n if (!settings.allowedApps.includes(settings.mainApp!)) {\n errors.push('Main app must be included in allowed apps list');\n }\n }\n\n const custom = settings.custom?.kiosk as Partial<KioskSettings> | undefined;\n if (custom?.mode === 'multi-app' && !settings.allowedApps?.length) {\n errors.push('Multi-app kiosk mode requires allowed apps list');\n }\n\n if (custom?.screenBrightness !== undefined && custom.screenBrightness !== 'auto') {\n if (typeof custom.screenBrightness === 'number') {\n if (custom.screenBrightness < 0 || custom.screenBrightness > 255) {\n errors.push('Screen brightness must be between 0-255 or \"auto\"');\n }\n }\n }\n }\n\n return {\n valid: errors.length === 0,\n errors: errors.length > 0 ? errors : undefined,\n };\n }\n\n // Define plugin routes\n const routes: PluginRoute[] = [\n // Get kiosk status for a device\n {\n method: 'GET',\n path: '/kiosk/:deviceId/status',\n auth: true,\n admin: true,\n handler: async (context: any) => {\n const { deviceId } = context.req.param();\n const state = getKioskState(deviceId);\n\n if (!state) {\n return context.json({ enabled: false });\n }\n\n return context.json({\n ...state,\n lockedSince: state.lockedSince?.toISOString(),\n lastExitAttempt: state.lastExitAttempt?.toISOString(),\n lockoutUntil: state.lockoutUntil?.toISOString(),\n });\n },\n },\n\n // Enter kiosk mode\n {\n method: 'POST',\n path: '/kiosk/:deviceId/enter',\n auth: true,\n admin: true,\n handler: async (context: any) => {\n const { deviceId } = context.req.param();\n const body = await context.req.json();\n\n const command = await mdm.commands.send({\n deviceId,\n type: 'enterKiosk',\n payload: { app: body.app },\n });\n\n return context.json({ success: true, commandId: command.id });\n },\n },\n\n // Exit kiosk mode\n {\n method: 'POST',\n path: '/kiosk/:deviceId/exit',\n auth: true,\n admin: true,\n handler: async (context: any) => {\n const { deviceId } = context.req.param();\n\n const command = await mdm.commands.send({\n deviceId,\n type: 'exitKiosk',\n });\n\n return context.json({ success: true, commandId: command.id });\n },\n },\n\n // Get all devices in kiosk mode\n {\n method: 'GET',\n path: '/kiosk/devices',\n auth: true,\n admin: true,\n handler: async (context: any) => {\n const kioskDevices = Array.from(kioskStates.entries())\n .filter(([_, state]) => state.enabled)\n .map(([_, state]) => ({\n ...state,\n lockedSince: state.lockedSince?.toISOString(),\n }));\n\n return context.json({ devices: kioskDevices });\n },\n },\n\n // Report exit attempt from device\n {\n method: 'POST',\n path: '/kiosk/exit-attempt',\n auth: true,\n handler: async (context: any) => {\n const body = await context.req.json();\n const { deviceId, success, password } = body;\n\n const state = getKioskState(deviceId);\n if (!state?.enabled) {\n return context.json({ error: 'Device not in kiosk mode' }, 400);\n }\n\n // Check lockout\n if (state.lockedOut && state.lockoutUntil) {\n if (new Date() < state.lockoutUntil) {\n return context.json({\n error: 'Exit attempts locked',\n lockoutUntil: state.lockoutUntil.toISOString(),\n }, 403);\n } else {\n // Lockout expired\n updateKioskState(deviceId, {\n lockedOut: false,\n lockoutUntil: undefined,\n exitAttempts: 0,\n });\n }\n }\n\n if (success) {\n // Successful exit\n updateKioskState(deviceId, {\n enabled: false,\n exitAttempts: 0,\n });\n\n await mdm.emit('custom', {\n type: 'kiosk.exited',\n deviceId,\n method: 'local',\n timestamp: new Date().toISOString(),\n });\n\n return context.json({ success: true });\n } else {\n // Failed attempt\n const newAttempts = state.exitAttempts + 1;\n\n if (newAttempts >= maxExitAttempts) {\n // Lock out\n const lockoutUntil = new Date(\n Date.now() + lockoutDuration * 60 * 1000\n );\n\n updateKioskState(deviceId, {\n exitAttempts: newAttempts,\n lastExitAttempt: new Date(),\n lockedOut: true,\n lockoutUntil,\n });\n\n await mdm.emit('custom', {\n type: 'kiosk.lockout',\n deviceId,\n attempts: newAttempts,\n lockoutUntil: lockoutUntil.toISOString(),\n });\n\n return context.json({\n error: 'Max attempts exceeded',\n lockedOut: true,\n lockoutUntil: lockoutUntil.toISOString(),\n }, 403);\n } else {\n updateKioskState(deviceId, {\n exitAttempts: newAttempts,\n lastExitAttempt: new Date(),\n });\n\n return context.json({\n error: 'Invalid password',\n attemptsRemaining: maxExitAttempts - newAttempts,\n }, 401);\n }\n }\n },\n },\n\n // Report kiosk app crash from device\n {\n method: 'POST',\n path: '/kiosk/app-crash',\n auth: true,\n handler: async (context: any) => {\n const body = await context.req.json();\n const { deviceId, packageName, error } = body;\n\n await mdm.emit('custom', {\n type: 'kiosk.appCrash',\n deviceId,\n packageName,\n error,\n timestamp: new Date().toISOString(),\n });\n\n const state = getKioskState(deviceId);\n const shouldRestart = state?.enabled && autoRestart;\n\n return context.json({\n restart: shouldRestart,\n restartDelay: autoRestartDelay,\n lockDevice: lockOnCrash,\n });\n },\n },\n ];\n\n return {\n name: 'kiosk',\n version: '1.0.0',\n\n async onInit(instance: MDMInstance): Promise<void> {\n mdm = instance;\n console.log('[OpenMDM Kiosk] Plugin initialized');\n },\n\n async onDestroy(): Promise<void> {\n kioskStates.clear();\n console.log('[OpenMDM Kiosk] Plugin destroyed');\n },\n\n routes,\n\n async onDeviceEnrolled(device: Device): Promise<void> {\n // Initialize kiosk state for new device\n if (device.policyId) {\n const policy = await mdm.policies.get(device.policyId);\n if (policy) {\n const kioskSettings = getKioskSettings(policy);\n if (kioskSettings?.enabled) {\n updateKioskState(device.id, {\n enabled: true,\n mode: kioskSettings.mode,\n mainApp: kioskSettings.mainApp,\n lockedSince: new Date(),\n });\n }\n }\n }\n },\n\n async onHeartbeat(device: Device, heartbeat: Heartbeat): Promise<void> {\n const state = getKioskState(device.id);\n\n if (state?.enabled && heartbeat.runningApps) {\n // Check if kiosk app is still running\n const isKioskAppRunning = heartbeat.runningApps.includes(state.mainApp);\n\n if (!isKioskAppRunning && autoRestart) {\n // Kiosk app is not running - send restart command\n await mdm.commands.send({\n deviceId: device.id,\n type: 'runApp',\n payload: { packageName: state.mainApp },\n });\n }\n }\n },\n\n policySchema: {\n kioskMode: { type: 'boolean', description: 'Enable kiosk mode' },\n mainApp: {\n type: 'string',\n description: 'Main kiosk app package name',\n },\n allowedApps: {\n type: 'array',\n items: { type: 'string' },\n description: 'Allowed apps in kiosk mode',\n },\n kioskExitPassword: {\n type: 'string',\n description: 'Password to exit kiosk mode',\n },\n },\n\n validatePolicy: async (settings: PolicySettings) => {\n return validateKioskPolicy(settings);\n },\n\n commandTypes: ['enterKiosk', 'exitKiosk'] as any,\n\n executeCommand: async (\n device: Device,\n command: Command\n ): Promise<CommandResult> => {\n switch (command.type) {\n case 'enterKiosk':\n return handleEnterKiosk(device, command);\n case 'exitKiosk':\n return handleExitKiosk(device, command);\n default:\n return {\n success: false,\n message: `Unknown command type: ${command.type}`,\n };\n }\n },\n };\n}\n\n// ============================================\n// Exports\n// ============================================\n\nexport type { MDMPlugin };\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openmdm/plugin-kiosk",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Kiosk mode plugin for OpenMDM - single-app lock down mode for Android devices",
|
|
5
|
+
"author": "OpenMDM Contributors",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@openmdm/core": "0.2.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"tsup": "^8.0.0",
|
|
26
|
+
"typescript": "^5.5.0"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"openmdm",
|
|
30
|
+
"kiosk",
|
|
31
|
+
"lockdown",
|
|
32
|
+
"single-app",
|
|
33
|
+
"mdm"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/azoila/openmdm.git",
|
|
39
|
+
"directory": "packages/plugins/kiosk"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://openmdm.dev",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/azoila/openmdm/issues"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsup",
|
|
50
|
+
"dev": "tsup --watch",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"clean": "rm -rf dist"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenMDM Kiosk Mode Plugin
|
|
3
|
+
*
|
|
4
|
+
* Provides kiosk/lockdown mode functionality for Android devices.
|
|
5
|
+
* Supports single-app mode, multi-app kiosk, and screen pinning.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { createMDM } from '@openmdm/core';
|
|
10
|
+
* import { kioskPlugin } from '@openmdm/plugin-kiosk';
|
|
11
|
+
*
|
|
12
|
+
* const mdm = createMDM({
|
|
13
|
+
* database: drizzleAdapter(db),
|
|
14
|
+
* plugins: [
|
|
15
|
+
* kioskPlugin({
|
|
16
|
+
* defaultExitPassword: 'admin123',
|
|
17
|
+
* allowRemoteExit: true,
|
|
18
|
+
* }),
|
|
19
|
+
* ],
|
|
20
|
+
* });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
MDMPlugin,
|
|
26
|
+
MDMInstance,
|
|
27
|
+
Device,
|
|
28
|
+
Policy,
|
|
29
|
+
PolicySettings,
|
|
30
|
+
Command,
|
|
31
|
+
CommandResult,
|
|
32
|
+
Heartbeat,
|
|
33
|
+
PluginRoute,
|
|
34
|
+
} from '@openmdm/core';
|
|
35
|
+
|
|
36
|
+
// ============================================
|
|
37
|
+
// Kiosk Types
|
|
38
|
+
// ============================================
|
|
39
|
+
|
|
40
|
+
export interface KioskPluginOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Default password for exiting kiosk mode
|
|
43
|
+
* Can be overridden per-policy
|
|
44
|
+
*/
|
|
45
|
+
defaultExitPassword?: string;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Allow remote exit command from server
|
|
49
|
+
*/
|
|
50
|
+
allowRemoteExit?: boolean;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Auto-lock device when kiosk app crashes
|
|
54
|
+
*/
|
|
55
|
+
lockOnCrash?: boolean;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Restart kiosk app if it closes
|
|
59
|
+
*/
|
|
60
|
+
autoRestart?: boolean;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Auto-restart delay in milliseconds
|
|
64
|
+
*/
|
|
65
|
+
autoRestartDelay?: number;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Maximum exit password attempts before lock
|
|
69
|
+
*/
|
|
70
|
+
maxExitAttempts?: number;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Lockout duration in minutes after max attempts
|
|
74
|
+
*/
|
|
75
|
+
lockoutDuration?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface KioskSettings {
|
|
79
|
+
/** Enable kiosk mode */
|
|
80
|
+
enabled: boolean;
|
|
81
|
+
|
|
82
|
+
/** Kiosk mode type */
|
|
83
|
+
mode: 'single-app' | 'multi-app' | 'screen-pin';
|
|
84
|
+
|
|
85
|
+
/** Main/launcher app package name */
|
|
86
|
+
mainApp: string;
|
|
87
|
+
|
|
88
|
+
/** Allowed apps in multi-app mode */
|
|
89
|
+
allowedApps?: string[];
|
|
90
|
+
|
|
91
|
+
/** Password to exit kiosk mode (device-side) */
|
|
92
|
+
exitPassword?: string;
|
|
93
|
+
|
|
94
|
+
/** Secret gesture to trigger exit (e.g., '5-tap-corners') */
|
|
95
|
+
exitGesture?: string;
|
|
96
|
+
|
|
97
|
+
/** Lock status bar */
|
|
98
|
+
lockStatusBar?: boolean;
|
|
99
|
+
|
|
100
|
+
/** Lock navigation bar */
|
|
101
|
+
lockNavigationBar?: boolean;
|
|
102
|
+
|
|
103
|
+
/** Disable home button */
|
|
104
|
+
disableHomeButton?: boolean;
|
|
105
|
+
|
|
106
|
+
/** Disable recent apps button */
|
|
107
|
+
disableRecentApps?: boolean;
|
|
108
|
+
|
|
109
|
+
/** Disable power button (soft) */
|
|
110
|
+
disablePowerButton?: boolean;
|
|
111
|
+
|
|
112
|
+
/** Disable volume buttons */
|
|
113
|
+
disableVolumeButtons?: boolean;
|
|
114
|
+
|
|
115
|
+
/** Lock screen orientation */
|
|
116
|
+
lockOrientation?: 'portrait' | 'landscape' | 'auto';
|
|
117
|
+
|
|
118
|
+
/** Disable notifications */
|
|
119
|
+
disableNotifications?: boolean;
|
|
120
|
+
|
|
121
|
+
/** Allowed notification packages */
|
|
122
|
+
allowedNotifications?: string[];
|
|
123
|
+
|
|
124
|
+
/** Keep screen on */
|
|
125
|
+
keepScreenOn?: boolean;
|
|
126
|
+
|
|
127
|
+
/** Screen brightness (0-255, or 'auto') */
|
|
128
|
+
screenBrightness?: number | 'auto';
|
|
129
|
+
|
|
130
|
+
/** Auto-launch on boot */
|
|
131
|
+
launchOnBoot?: boolean;
|
|
132
|
+
|
|
133
|
+
/** Auto-restart on crash */
|
|
134
|
+
restartOnCrash?: boolean;
|
|
135
|
+
|
|
136
|
+
/** Restart delay in ms */
|
|
137
|
+
restartDelay?: number;
|
|
138
|
+
|
|
139
|
+
/** Custom wallpaper URL */
|
|
140
|
+
wallpaperUrl?: string;
|
|
141
|
+
|
|
142
|
+
/** Hide system UI elements */
|
|
143
|
+
immersiveMode?: boolean;
|
|
144
|
+
|
|
145
|
+
/** Allow status bar pull-down for specific items */
|
|
146
|
+
allowedStatusBarItems?: ('wifi' | 'bluetooth' | 'airplane' | 'battery')[];
|
|
147
|
+
|
|
148
|
+
/** Show clock/time */
|
|
149
|
+
showClock?: boolean;
|
|
150
|
+
|
|
151
|
+
/** Show battery indicator */
|
|
152
|
+
showBattery?: boolean;
|
|
153
|
+
|
|
154
|
+
/** Custom exit password per device (encrypted) */
|
|
155
|
+
deviceExitPasswords?: Record<string, string>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface KioskState {
|
|
159
|
+
deviceId: string;
|
|
160
|
+
enabled: boolean;
|
|
161
|
+
mode: 'single-app' | 'multi-app' | 'screen-pin';
|
|
162
|
+
mainApp: string;
|
|
163
|
+
activeApp?: string;
|
|
164
|
+
lockedSince?: Date;
|
|
165
|
+
exitAttempts: number;
|
|
166
|
+
lastExitAttempt?: Date;
|
|
167
|
+
lockedOut: boolean;
|
|
168
|
+
lockoutUntil?: Date;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================
|
|
172
|
+
// Kiosk Plugin Implementation
|
|
173
|
+
// ============================================
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create kiosk mode plugin
|
|
177
|
+
*/
|
|
178
|
+
export function kioskPlugin(options: KioskPluginOptions = {}): MDMPlugin {
|
|
179
|
+
const {
|
|
180
|
+
defaultExitPassword = 'admin',
|
|
181
|
+
allowRemoteExit = true,
|
|
182
|
+
lockOnCrash = true,
|
|
183
|
+
autoRestart = true,
|
|
184
|
+
autoRestartDelay = 1000,
|
|
185
|
+
maxExitAttempts = 5,
|
|
186
|
+
lockoutDuration = 15,
|
|
187
|
+
} = options;
|
|
188
|
+
|
|
189
|
+
let mdm: MDMInstance;
|
|
190
|
+
|
|
191
|
+
// In-memory kiosk state (should be persisted to DB in production)
|
|
192
|
+
const kioskStates = new Map<string, KioskState>();
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get kiosk settings from policy
|
|
196
|
+
*/
|
|
197
|
+
function getKioskSettings(policy: Policy): KioskSettings | null {
|
|
198
|
+
const settings = policy.settings;
|
|
199
|
+
|
|
200
|
+
if (!settings.kioskMode || !settings.mainApp) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
enabled: settings.kioskMode,
|
|
206
|
+
mode: 'single-app', // Default mode
|
|
207
|
+
mainApp: settings.mainApp,
|
|
208
|
+
allowedApps: settings.allowedApps,
|
|
209
|
+
exitPassword: settings.kioskExitPassword || defaultExitPassword,
|
|
210
|
+
lockStatusBar: settings.lockStatusBar ?? true,
|
|
211
|
+
lockNavigationBar: settings.lockNavigationBar ?? true,
|
|
212
|
+
disableHomeButton: true,
|
|
213
|
+
disableRecentApps: true,
|
|
214
|
+
disablePowerButton: settings.lockPowerButton ?? false,
|
|
215
|
+
keepScreenOn: true,
|
|
216
|
+
launchOnBoot: true,
|
|
217
|
+
restartOnCrash: autoRestart,
|
|
218
|
+
restartDelay: autoRestartDelay,
|
|
219
|
+
immersiveMode: true,
|
|
220
|
+
showClock: true,
|
|
221
|
+
showBattery: true,
|
|
222
|
+
...(settings.custom?.kiosk as Partial<KioskSettings>),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get or create kiosk state for device
|
|
228
|
+
*/
|
|
229
|
+
function getKioskState(deviceId: string): KioskState | undefined {
|
|
230
|
+
return kioskStates.get(deviceId);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Update kiosk state
|
|
235
|
+
*/
|
|
236
|
+
function updateKioskState(
|
|
237
|
+
deviceId: string,
|
|
238
|
+
updates: Partial<KioskState>
|
|
239
|
+
): KioskState {
|
|
240
|
+
const current = kioskStates.get(deviceId) || {
|
|
241
|
+
deviceId,
|
|
242
|
+
enabled: false,
|
|
243
|
+
mode: 'single-app' as const,
|
|
244
|
+
mainApp: '',
|
|
245
|
+
exitAttempts: 0,
|
|
246
|
+
lockedOut: false,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const updated = { ...current, ...updates };
|
|
250
|
+
kioskStates.set(deviceId, updated);
|
|
251
|
+
return updated;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Handle exit kiosk command
|
|
256
|
+
*/
|
|
257
|
+
async function handleExitKiosk(
|
|
258
|
+
device: Device,
|
|
259
|
+
command: Command
|
|
260
|
+
): Promise<CommandResult> {
|
|
261
|
+
if (!allowRemoteExit) {
|
|
262
|
+
return {
|
|
263
|
+
success: false,
|
|
264
|
+
message: 'Remote kiosk exit is disabled',
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const state = getKioskState(device.id);
|
|
269
|
+
if (!state?.enabled) {
|
|
270
|
+
return {
|
|
271
|
+
success: true,
|
|
272
|
+
message: 'Device is not in kiosk mode',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Update state
|
|
277
|
+
updateKioskState(device.id, {
|
|
278
|
+
enabled: false,
|
|
279
|
+
exitAttempts: 0,
|
|
280
|
+
lockedOut: false,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Emit event
|
|
284
|
+
await mdm.emit('custom', {
|
|
285
|
+
type: 'kiosk.exited',
|
|
286
|
+
deviceId: device.id,
|
|
287
|
+
method: 'remote',
|
|
288
|
+
timestamp: new Date().toISOString(),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
success: true,
|
|
293
|
+
message: 'Kiosk mode exit command sent',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Handle enter kiosk command
|
|
299
|
+
*/
|
|
300
|
+
async function handleEnterKiosk(
|
|
301
|
+
device: Device,
|
|
302
|
+
command: Command
|
|
303
|
+
): Promise<CommandResult> {
|
|
304
|
+
const payload = command.payload as { app?: string } | undefined;
|
|
305
|
+
|
|
306
|
+
// Get policy kiosk settings
|
|
307
|
+
let kioskSettings: KioskSettings | null = null;
|
|
308
|
+
if (device.policyId) {
|
|
309
|
+
const policy = await mdm.policies.get(device.policyId);
|
|
310
|
+
if (policy) {
|
|
311
|
+
kioskSettings = getKioskSettings(policy);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const mainApp = payload?.app || kioskSettings?.mainApp;
|
|
316
|
+
if (!mainApp) {
|
|
317
|
+
return {
|
|
318
|
+
success: false,
|
|
319
|
+
message: 'No main app specified for kiosk mode',
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Update state
|
|
324
|
+
updateKioskState(device.id, {
|
|
325
|
+
enabled: true,
|
|
326
|
+
mode: kioskSettings?.mode || 'single-app',
|
|
327
|
+
mainApp,
|
|
328
|
+
lockedSince: new Date(),
|
|
329
|
+
exitAttempts: 0,
|
|
330
|
+
lockedOut: false,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Emit event
|
|
334
|
+
await mdm.emit('custom', {
|
|
335
|
+
type: 'kiosk.entered',
|
|
336
|
+
deviceId: device.id,
|
|
337
|
+
mainApp,
|
|
338
|
+
timestamp: new Date().toISOString(),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
success: true,
|
|
343
|
+
message: `Kiosk mode activated with ${mainApp}`,
|
|
344
|
+
data: { mainApp },
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Validate kiosk policy settings
|
|
350
|
+
*/
|
|
351
|
+
function validateKioskPolicy(
|
|
352
|
+
settings: PolicySettings
|
|
353
|
+
): { valid: boolean; errors?: string[] } {
|
|
354
|
+
const errors: string[] = [];
|
|
355
|
+
|
|
356
|
+
if (settings.kioskMode) {
|
|
357
|
+
if (!settings.mainApp) {
|
|
358
|
+
errors.push('Kiosk mode requires a main app package name');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (settings.allowedApps && settings.allowedApps.length > 0) {
|
|
362
|
+
if (!settings.allowedApps.includes(settings.mainApp!)) {
|
|
363
|
+
errors.push('Main app must be included in allowed apps list');
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const custom = settings.custom?.kiosk as Partial<KioskSettings> | undefined;
|
|
368
|
+
if (custom?.mode === 'multi-app' && !settings.allowedApps?.length) {
|
|
369
|
+
errors.push('Multi-app kiosk mode requires allowed apps list');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (custom?.screenBrightness !== undefined && custom.screenBrightness !== 'auto') {
|
|
373
|
+
if (typeof custom.screenBrightness === 'number') {
|
|
374
|
+
if (custom.screenBrightness < 0 || custom.screenBrightness > 255) {
|
|
375
|
+
errors.push('Screen brightness must be between 0-255 or "auto"');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
valid: errors.length === 0,
|
|
383
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Define plugin routes
|
|
388
|
+
const routes: PluginRoute[] = [
|
|
389
|
+
// Get kiosk status for a device
|
|
390
|
+
{
|
|
391
|
+
method: 'GET',
|
|
392
|
+
path: '/kiosk/:deviceId/status',
|
|
393
|
+
auth: true,
|
|
394
|
+
admin: true,
|
|
395
|
+
handler: async (context: any) => {
|
|
396
|
+
const { deviceId } = context.req.param();
|
|
397
|
+
const state = getKioskState(deviceId);
|
|
398
|
+
|
|
399
|
+
if (!state) {
|
|
400
|
+
return context.json({ enabled: false });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return context.json({
|
|
404
|
+
...state,
|
|
405
|
+
lockedSince: state.lockedSince?.toISOString(),
|
|
406
|
+
lastExitAttempt: state.lastExitAttempt?.toISOString(),
|
|
407
|
+
lockoutUntil: state.lockoutUntil?.toISOString(),
|
|
408
|
+
});
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
// Enter kiosk mode
|
|
413
|
+
{
|
|
414
|
+
method: 'POST',
|
|
415
|
+
path: '/kiosk/:deviceId/enter',
|
|
416
|
+
auth: true,
|
|
417
|
+
admin: true,
|
|
418
|
+
handler: async (context: any) => {
|
|
419
|
+
const { deviceId } = context.req.param();
|
|
420
|
+
const body = await context.req.json();
|
|
421
|
+
|
|
422
|
+
const command = await mdm.commands.send({
|
|
423
|
+
deviceId,
|
|
424
|
+
type: 'enterKiosk',
|
|
425
|
+
payload: { app: body.app },
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
return context.json({ success: true, commandId: command.id });
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
// Exit kiosk mode
|
|
433
|
+
{
|
|
434
|
+
method: 'POST',
|
|
435
|
+
path: '/kiosk/:deviceId/exit',
|
|
436
|
+
auth: true,
|
|
437
|
+
admin: true,
|
|
438
|
+
handler: async (context: any) => {
|
|
439
|
+
const { deviceId } = context.req.param();
|
|
440
|
+
|
|
441
|
+
const command = await mdm.commands.send({
|
|
442
|
+
deviceId,
|
|
443
|
+
type: 'exitKiosk',
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
return context.json({ success: true, commandId: command.id });
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
// Get all devices in kiosk mode
|
|
451
|
+
{
|
|
452
|
+
method: 'GET',
|
|
453
|
+
path: '/kiosk/devices',
|
|
454
|
+
auth: true,
|
|
455
|
+
admin: true,
|
|
456
|
+
handler: async (context: any) => {
|
|
457
|
+
const kioskDevices = Array.from(kioskStates.entries())
|
|
458
|
+
.filter(([_, state]) => state.enabled)
|
|
459
|
+
.map(([_, state]) => ({
|
|
460
|
+
...state,
|
|
461
|
+
lockedSince: state.lockedSince?.toISOString(),
|
|
462
|
+
}));
|
|
463
|
+
|
|
464
|
+
return context.json({ devices: kioskDevices });
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
// Report exit attempt from device
|
|
469
|
+
{
|
|
470
|
+
method: 'POST',
|
|
471
|
+
path: '/kiosk/exit-attempt',
|
|
472
|
+
auth: true,
|
|
473
|
+
handler: async (context: any) => {
|
|
474
|
+
const body = await context.req.json();
|
|
475
|
+
const { deviceId, success, password } = body;
|
|
476
|
+
|
|
477
|
+
const state = getKioskState(deviceId);
|
|
478
|
+
if (!state?.enabled) {
|
|
479
|
+
return context.json({ error: 'Device not in kiosk mode' }, 400);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Check lockout
|
|
483
|
+
if (state.lockedOut && state.lockoutUntil) {
|
|
484
|
+
if (new Date() < state.lockoutUntil) {
|
|
485
|
+
return context.json({
|
|
486
|
+
error: 'Exit attempts locked',
|
|
487
|
+
lockoutUntil: state.lockoutUntil.toISOString(),
|
|
488
|
+
}, 403);
|
|
489
|
+
} else {
|
|
490
|
+
// Lockout expired
|
|
491
|
+
updateKioskState(deviceId, {
|
|
492
|
+
lockedOut: false,
|
|
493
|
+
lockoutUntil: undefined,
|
|
494
|
+
exitAttempts: 0,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (success) {
|
|
500
|
+
// Successful exit
|
|
501
|
+
updateKioskState(deviceId, {
|
|
502
|
+
enabled: false,
|
|
503
|
+
exitAttempts: 0,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
await mdm.emit('custom', {
|
|
507
|
+
type: 'kiosk.exited',
|
|
508
|
+
deviceId,
|
|
509
|
+
method: 'local',
|
|
510
|
+
timestamp: new Date().toISOString(),
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
return context.json({ success: true });
|
|
514
|
+
} else {
|
|
515
|
+
// Failed attempt
|
|
516
|
+
const newAttempts = state.exitAttempts + 1;
|
|
517
|
+
|
|
518
|
+
if (newAttempts >= maxExitAttempts) {
|
|
519
|
+
// Lock out
|
|
520
|
+
const lockoutUntil = new Date(
|
|
521
|
+
Date.now() + lockoutDuration * 60 * 1000
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
updateKioskState(deviceId, {
|
|
525
|
+
exitAttempts: newAttempts,
|
|
526
|
+
lastExitAttempt: new Date(),
|
|
527
|
+
lockedOut: true,
|
|
528
|
+
lockoutUntil,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
await mdm.emit('custom', {
|
|
532
|
+
type: 'kiosk.lockout',
|
|
533
|
+
deviceId,
|
|
534
|
+
attempts: newAttempts,
|
|
535
|
+
lockoutUntil: lockoutUntil.toISOString(),
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
return context.json({
|
|
539
|
+
error: 'Max attempts exceeded',
|
|
540
|
+
lockedOut: true,
|
|
541
|
+
lockoutUntil: lockoutUntil.toISOString(),
|
|
542
|
+
}, 403);
|
|
543
|
+
} else {
|
|
544
|
+
updateKioskState(deviceId, {
|
|
545
|
+
exitAttempts: newAttempts,
|
|
546
|
+
lastExitAttempt: new Date(),
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
return context.json({
|
|
550
|
+
error: 'Invalid password',
|
|
551
|
+
attemptsRemaining: maxExitAttempts - newAttempts,
|
|
552
|
+
}, 401);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
// Report kiosk app crash from device
|
|
559
|
+
{
|
|
560
|
+
method: 'POST',
|
|
561
|
+
path: '/kiosk/app-crash',
|
|
562
|
+
auth: true,
|
|
563
|
+
handler: async (context: any) => {
|
|
564
|
+
const body = await context.req.json();
|
|
565
|
+
const { deviceId, packageName, error } = body;
|
|
566
|
+
|
|
567
|
+
await mdm.emit('custom', {
|
|
568
|
+
type: 'kiosk.appCrash',
|
|
569
|
+
deviceId,
|
|
570
|
+
packageName,
|
|
571
|
+
error,
|
|
572
|
+
timestamp: new Date().toISOString(),
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const state = getKioskState(deviceId);
|
|
576
|
+
const shouldRestart = state?.enabled && autoRestart;
|
|
577
|
+
|
|
578
|
+
return context.json({
|
|
579
|
+
restart: shouldRestart,
|
|
580
|
+
restartDelay: autoRestartDelay,
|
|
581
|
+
lockDevice: lockOnCrash,
|
|
582
|
+
});
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
];
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
name: 'kiosk',
|
|
589
|
+
version: '1.0.0',
|
|
590
|
+
|
|
591
|
+
async onInit(instance: MDMInstance): Promise<void> {
|
|
592
|
+
mdm = instance;
|
|
593
|
+
console.log('[OpenMDM Kiosk] Plugin initialized');
|
|
594
|
+
},
|
|
595
|
+
|
|
596
|
+
async onDestroy(): Promise<void> {
|
|
597
|
+
kioskStates.clear();
|
|
598
|
+
console.log('[OpenMDM Kiosk] Plugin destroyed');
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
routes,
|
|
602
|
+
|
|
603
|
+
async onDeviceEnrolled(device: Device): Promise<void> {
|
|
604
|
+
// Initialize kiosk state for new device
|
|
605
|
+
if (device.policyId) {
|
|
606
|
+
const policy = await mdm.policies.get(device.policyId);
|
|
607
|
+
if (policy) {
|
|
608
|
+
const kioskSettings = getKioskSettings(policy);
|
|
609
|
+
if (kioskSettings?.enabled) {
|
|
610
|
+
updateKioskState(device.id, {
|
|
611
|
+
enabled: true,
|
|
612
|
+
mode: kioskSettings.mode,
|
|
613
|
+
mainApp: kioskSettings.mainApp,
|
|
614
|
+
lockedSince: new Date(),
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
async onHeartbeat(device: Device, heartbeat: Heartbeat): Promise<void> {
|
|
622
|
+
const state = getKioskState(device.id);
|
|
623
|
+
|
|
624
|
+
if (state?.enabled && heartbeat.runningApps) {
|
|
625
|
+
// Check if kiosk app is still running
|
|
626
|
+
const isKioskAppRunning = heartbeat.runningApps.includes(state.mainApp);
|
|
627
|
+
|
|
628
|
+
if (!isKioskAppRunning && autoRestart) {
|
|
629
|
+
// Kiosk app is not running - send restart command
|
|
630
|
+
await mdm.commands.send({
|
|
631
|
+
deviceId: device.id,
|
|
632
|
+
type: 'runApp',
|
|
633
|
+
payload: { packageName: state.mainApp },
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
policySchema: {
|
|
640
|
+
kioskMode: { type: 'boolean', description: 'Enable kiosk mode' },
|
|
641
|
+
mainApp: {
|
|
642
|
+
type: 'string',
|
|
643
|
+
description: 'Main kiosk app package name',
|
|
644
|
+
},
|
|
645
|
+
allowedApps: {
|
|
646
|
+
type: 'array',
|
|
647
|
+
items: { type: 'string' },
|
|
648
|
+
description: 'Allowed apps in kiosk mode',
|
|
649
|
+
},
|
|
650
|
+
kioskExitPassword: {
|
|
651
|
+
type: 'string',
|
|
652
|
+
description: 'Password to exit kiosk mode',
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
|
|
656
|
+
validatePolicy: async (settings: PolicySettings) => {
|
|
657
|
+
return validateKioskPolicy(settings);
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
commandTypes: ['enterKiosk', 'exitKiosk'] as any,
|
|
661
|
+
|
|
662
|
+
executeCommand: async (
|
|
663
|
+
device: Device,
|
|
664
|
+
command: Command
|
|
665
|
+
): Promise<CommandResult> => {
|
|
666
|
+
switch (command.type) {
|
|
667
|
+
case 'enterKiosk':
|
|
668
|
+
return handleEnterKiosk(device, command);
|
|
669
|
+
case 'exitKiosk':
|
|
670
|
+
return handleExitKiosk(device, command);
|
|
671
|
+
default:
|
|
672
|
+
return {
|
|
673
|
+
success: false,
|
|
674
|
+
message: `Unknown command type: ${command.type}`,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ============================================
|
|
682
|
+
// Exports
|
|
683
|
+
// ============================================
|
|
684
|
+
|
|
685
|
+
export type { MDMPlugin };
|