@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 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.
@@ -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 };