frida-ui-runtime 2.0.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/README.md +181 -0
- package/lib/android-listener.d.ts +20 -0
- package/lib/android-listener.js +92 -0
- package/lib/android-runtime.d.ts +25 -0
- package/lib/android-runtime.js +310 -0
- package/lib/api.d.ts +1 -0
- package/lib/api.js +156 -0
- package/lib/component/button.d.ts +11 -0
- package/lib/component/button.js +61 -0
- package/lib/component/category.d.ts +9 -0
- package/lib/component/category.js +57 -0
- package/lib/component/checkBox.d.ts +33 -0
- package/lib/component/checkBox.js +276 -0
- package/lib/component/collapsible.d.ts +19 -0
- package/lib/component/collapsible.js +197 -0
- package/lib/component/image.d.ts +18 -0
- package/lib/component/image.js +85 -0
- package/lib/component/infoCardBox.d.ts +13 -0
- package/lib/component/infoCardBox.js +68 -0
- package/lib/component/input.d.ts +41 -0
- package/lib/component/input.js +342 -0
- package/lib/component/selector.d.ts +31 -0
- package/lib/component/selector.js +246 -0
- package/lib/component/slider.d.ts +18 -0
- package/lib/component/slider.js +156 -0
- package/lib/component/style/style.d.ts +4 -0
- package/lib/component/style/style.js +325 -0
- package/lib/component/style/theme.d.ts +33 -0
- package/lib/component/style/theme.js +24 -0
- package/lib/component/switch.d.ts +12 -0
- package/lib/component/switch.js +117 -0
- package/lib/component/text.d.ts +9 -0
- package/lib/component/text.js +36 -0
- package/lib/component/ui-components.d.ts +34 -0
- package/lib/component/ui-components.js +108 -0
- package/lib/component/views/header-view.d.ts +20 -0
- package/lib/component/views/header-view.js +198 -0
- package/lib/component/views/log-view.d.ts +41 -0
- package/lib/component/views/log-view.js +511 -0
- package/lib/component/views/tabs-view.d.ts +41 -0
- package/lib/component/views/tabs-view.js +334 -0
- package/lib/constant-config.d.ts +5 -0
- package/lib/constant-config.js +3 -0
- package/lib/event-emitter.d.ts +10 -0
- package/lib/event-emitter.js +52 -0
- package/lib/float-menu.d.ts +103 -0
- package/lib/float-menu.js +648 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.js +16 -0
- package/lib/java-runtime.d.ts +2 -0
- package/lib/java-runtime.js +2 -0
- package/lib/logger.d.ts +43 -0
- package/lib/logger.js +200 -0
- package/lib/runtime/component-registry.d.ts +24 -0
- package/lib/runtime/component-registry.js +48 -0
- package/lib/runtime/launch-sequence.d.ts +5 -0
- package/lib/runtime/launch-sequence.js +20 -0
- package/lib/runtime/lifecycle-controller.d.ts +9 -0
- package/lib/runtime/lifecycle-controller.js +31 -0
- package/lib/runtime/listener-registry.d.ts +7 -0
- package/lib/runtime/listener-registry.js +40 -0
- package/lib/runtime/overlay-options.d.ts +9 -0
- package/lib/runtime/overlay-options.js +42 -0
- package/lib/runtime/safe-runtime.d.ts +3 -0
- package/lib/runtime/safe-runtime.js +20 -0
- package/lib/utils.d.ts +8 -0
- package/lib/utils.js +13 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frida-ui-runtime
|
|
2
|
+
|
|
3
|
+
Lifecycle-safe Android overlay UI runtime for Frida scripts.
|
|
4
|
+
|
|
5
|
+
The runtime creates native Android views through the consumer-installed
|
|
6
|
+
`frida-java-bridge` peer dependency. The consumer selects the bridge version;
|
|
7
|
+
the runtime validates the capabilities it needs and never depends on a
|
|
8
|
+
REPL-provided global `Java` object.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Frida with Java support in the target Android process
|
|
13
|
+
- Overlay permission for the target application
|
|
14
|
+
- Android `WindowManager` access
|
|
15
|
+
- `frida-compile` for bundling TypeScript entrypoints
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install frida-ui-runtime frida-java-bridge
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import {
|
|
27
|
+
Button,
|
|
28
|
+
FloatMenu,
|
|
29
|
+
Slider,
|
|
30
|
+
Switch,
|
|
31
|
+
TextView,
|
|
32
|
+
} from "frida-ui-runtime";
|
|
33
|
+
|
|
34
|
+
async function main(): Promise<void> {
|
|
35
|
+
const menu = await FloatMenu.launch({
|
|
36
|
+
title: "Runtime Tools",
|
|
37
|
+
width: 820,
|
|
38
|
+
height: 1100,
|
|
39
|
+
icon: {
|
|
40
|
+
base64: iconBase64,
|
|
41
|
+
width: 160,
|
|
42
|
+
height: 160,
|
|
43
|
+
},
|
|
44
|
+
tabs: [
|
|
45
|
+
{ id: "controls", label: "Controls" },
|
|
46
|
+
{ id: "status", label: "Status" },
|
|
47
|
+
],
|
|
48
|
+
activeTab: "controls",
|
|
49
|
+
}, async menu => {
|
|
50
|
+
await menu.addComponent(
|
|
51
|
+
new Switch("enabled", "Enabled", false),
|
|
52
|
+
"controls",
|
|
53
|
+
);
|
|
54
|
+
await menu.addComponent(
|
|
55
|
+
new Slider("level", "Level", 0, 10, 3),
|
|
56
|
+
"controls",
|
|
57
|
+
);
|
|
58
|
+
await menu.addComponent(
|
|
59
|
+
new Button("run", "Run", "primary", () => console.log("run")),
|
|
60
|
+
"controls",
|
|
61
|
+
);
|
|
62
|
+
await menu.addComponent(
|
|
63
|
+
new TextView("status", "Ready"),
|
|
64
|
+
"status",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Components remain dynamic after the floating icon is visible.
|
|
69
|
+
await menu.addComponent(new TextView("late", "Added later"), "status");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
void main().catch(error => console.error(error));
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The entrypoint runs directly in a Gadget local script and does not require a
|
|
76
|
+
REPL. `FloatMenu.launch()` waits for the consumer-provided Java Bridge, Android
|
|
77
|
+
application context, and main thread. It shows the floating icon only after the
|
|
78
|
+
setup callback completes.
|
|
79
|
+
|
|
80
|
+
`frida-java-bridge` is an unrestricted peer dependency. Install one bridge
|
|
81
|
+
version in the consuming project and let the package manager resolve both the
|
|
82
|
+
application and this runtime to that instance. Version `7.0.4` is only the
|
|
83
|
+
library's development and regression-test baseline.
|
|
84
|
+
|
|
85
|
+
At startup the runtime checks for `use`, `scheduleOnMainThread`, and either
|
|
86
|
+
`performNow` or `perform`. An incompatible bridge fails early with
|
|
87
|
+
`JavaBridgeCompatibilityError` and a list of missing capabilities.
|
|
88
|
+
|
|
89
|
+
## Lifecycle
|
|
90
|
+
|
|
91
|
+
`FloatMenu` serializes lifecycle operations so concurrent calls cannot attach or detach the same window twice.
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
await menu.mount(); // Create and attach both windows, initially hidden
|
|
95
|
+
await menu.present("icon"); // Show only the floating icon
|
|
96
|
+
await menu.present("menu"); // Show only the menu
|
|
97
|
+
await menu.conceal(); // Hide visually; keep the icon hotspot touchable
|
|
98
|
+
await menu.toggle(); // Toggle icon/menu presentation
|
|
99
|
+
await menu.dispose(); // Remove windows and release listeners/components
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`FloatMenu.create()` remains available as a low-level API that returns a hidden
|
|
103
|
+
menu. Prefer `FloatMenu.launch()` for Gadget entrypoints. Calling `present()`
|
|
104
|
+
without a mode defaults to the floating icon.
|
|
105
|
+
|
|
106
|
+
The minimize button returns to the visible icon. The close button calls
|
|
107
|
+
`conceal()`: both surfaces disappear visually, but the icon window stays
|
|
108
|
+
transparent and touchable at its previous position. Tapping that location opens
|
|
109
|
+
the menu directly. The close interaction also shows a Toast explaining where to
|
|
110
|
+
tap. The views and component state remain mounted throughout.
|
|
111
|
+
|
|
112
|
+
Use `dispose()` only when the overlay is no longer needed and its state may be
|
|
113
|
+
released.
|
|
114
|
+
|
|
115
|
+
## State
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
const snapshot = menu.captureState();
|
|
119
|
+
await menu.conceal();
|
|
120
|
+
|
|
121
|
+
await menu.present("menu");
|
|
122
|
+
menu.restoreState(snapshot);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Component IDs must be unique. Duplicate IDs are rejected instead of silently replacing an existing component.
|
|
126
|
+
|
|
127
|
+
Components can be added or removed after launch, including while the overlay is
|
|
128
|
+
concealed:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
await menu.conceal();
|
|
132
|
+
await menu.addComponent(new TextView("dynamic", "Added while hidden"));
|
|
133
|
+
await menu.removeComponent("dynamic");
|
|
134
|
+
await menu.present();
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Overlay Permission
|
|
138
|
+
|
|
139
|
+
Permission is checked before any window is attached. If permission is missing,
|
|
140
|
+
the runtime shows a Toast and throws `OverlayPermissionError`. Override the
|
|
141
|
+
Toast text with `permissionDeniedMessage` when needed.
|
|
142
|
+
|
|
143
|
+
## Custom Icon
|
|
144
|
+
|
|
145
|
+
`icon.base64` accepts raw Base64 or a `data:image/...;base64,` value. File paths
|
|
146
|
+
and network URLs are intentionally unsupported. Invalid image data fails launch
|
|
147
|
+
with `InvalidOverlayIconError` instead of creating a blank icon window.
|
|
148
|
+
|
|
149
|
+
## Components
|
|
150
|
+
|
|
151
|
+
The root package exports:
|
|
152
|
+
|
|
153
|
+
- `Button`
|
|
154
|
+
- `Category`
|
|
155
|
+
- `CheckBoxGroup`
|
|
156
|
+
- `Collapsible`
|
|
157
|
+
- `NumberInput`
|
|
158
|
+
- `Selector`
|
|
159
|
+
- `Slider`
|
|
160
|
+
- `Switch`
|
|
161
|
+
- `TextInput`
|
|
162
|
+
- `TextView` (`Text` alias)
|
|
163
|
+
- `UIComponent` for custom controls
|
|
164
|
+
|
|
165
|
+
All component callbacks are isolated from the Java listener boundary. Callback failures are logged instead of escaping into Android's UI thread.
|
|
166
|
+
|
|
167
|
+
## Build And Test
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
npm test
|
|
171
|
+
npx frida-compile tests/device/overlay-smoke.ts -o tests/device/overlay-smoke.js
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The repository includes an independent Android host app in `android-test-app`. Device testing does not load an application's production Frida script.
|
|
175
|
+
|
|
176
|
+
## Compatibility Notes
|
|
177
|
+
|
|
178
|
+
- Window type selection checks available framework fields instead of trusting spoofable `SDK_INT` values.
|
|
179
|
+
- Application context lookup falls back across `ActivityThread` and `AppGlobals`.
|
|
180
|
+
- `WindowManager` wrappers are validated for `addView`, `updateViewLayout`, and `removeView`. This handles environments where a module such as Guise allows an interface cast but hides the inherited `ViewManager` methods.
|
|
181
|
+
- Display metrics are validated before use and fall back to real display metrics when spoofed values are invalid.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
type JavaCallback = (...args: any[]) => any;
|
|
2
|
+
export interface JavaListenerHandle {
|
|
3
|
+
readonly instance: any;
|
|
4
|
+
$new(): any;
|
|
5
|
+
dispose(): void;
|
|
6
|
+
}
|
|
7
|
+
export interface JavaListenerOptions {
|
|
8
|
+
key: string;
|
|
9
|
+
owner?: string;
|
|
10
|
+
interfaceClass: any;
|
|
11
|
+
callbacks: Record<string, JavaCallback>;
|
|
12
|
+
fallback?: Record<string, any>;
|
|
13
|
+
}
|
|
14
|
+
export declare function createJavaListener(options: JavaListenerOptions): JavaListenerHandle;
|
|
15
|
+
export declare function disposeJavaListenerOwner(owner: string): void;
|
|
16
|
+
export declare function getJavaListenerDiagnostics(): {
|
|
17
|
+
pools: number;
|
|
18
|
+
callbacks: number;
|
|
19
|
+
};
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import Java from "./java-runtime.js";
|
|
2
|
+
import { Logger } from "./logger.js";
|
|
3
|
+
const pools = new Map();
|
|
4
|
+
const owners = new Map();
|
|
5
|
+
const sessionSuffix = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
6
|
+
let nextListenerId = 1;
|
|
7
|
+
function sanitizeClassSegment(value) {
|
|
8
|
+
const sanitized = value.replace(/[^A-Za-z0-9_]/g, "_");
|
|
9
|
+
return /^[A-Za-z_]/.test(sanitized) ? sanitized : `L_${sanitized}`;
|
|
10
|
+
}
|
|
11
|
+
function createPool(options) {
|
|
12
|
+
const callbacks = new Map();
|
|
13
|
+
const fallback = options.fallback ?? {};
|
|
14
|
+
const methods = {};
|
|
15
|
+
for (const methodName of Object.keys(options.callbacks)) {
|
|
16
|
+
methods[methodName] = function (...args) {
|
|
17
|
+
const callbackId = String(this.callbackId.value);
|
|
18
|
+
const callback = callbacks.get(callbackId)?.[methodName];
|
|
19
|
+
if (!callback)
|
|
20
|
+
return fallback[methodName];
|
|
21
|
+
try {
|
|
22
|
+
return callback(...args);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
Logger.instance.error(`[JavaListener:${options.key}.${methodName}] callback failed:`, error);
|
|
26
|
+
return fallback[methodName];
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const listenerClass = Java.registerClass({
|
|
31
|
+
name: `com.fridauiruntime.listener.${sanitizeClassSegment(options.key)}_${sessionSuffix}`,
|
|
32
|
+
implements: [options.interfaceClass],
|
|
33
|
+
fields: { callbackId: "java.lang.String" },
|
|
34
|
+
methods,
|
|
35
|
+
});
|
|
36
|
+
return { listenerClass, callbacks, fallback };
|
|
37
|
+
}
|
|
38
|
+
export function createJavaListener(options) {
|
|
39
|
+
const methodNames = Object.keys(options.callbacks).sort().join(",");
|
|
40
|
+
const poolKey = `${options.key}:${methodNames}`;
|
|
41
|
+
let pool = pools.get(poolKey);
|
|
42
|
+
if (!pool) {
|
|
43
|
+
pool = createPool(options);
|
|
44
|
+
pools.set(poolKey, pool);
|
|
45
|
+
}
|
|
46
|
+
const callbackId = String(nextListenerId++);
|
|
47
|
+
pool.callbacks.set(callbackId, options.callbacks);
|
|
48
|
+
const instance = pool.listenerClass.$new();
|
|
49
|
+
instance.callbackId.value = callbackId;
|
|
50
|
+
let disposed = false;
|
|
51
|
+
const handle = {
|
|
52
|
+
instance,
|
|
53
|
+
$new: () => instance,
|
|
54
|
+
dispose() {
|
|
55
|
+
if (disposed)
|
|
56
|
+
return;
|
|
57
|
+
disposed = true;
|
|
58
|
+
pool.callbacks.delete(callbackId);
|
|
59
|
+
const owned = options.owner ? owners.get(options.owner) : undefined;
|
|
60
|
+
owned?.delete(handle.dispose);
|
|
61
|
+
if (options.owner && owned?.size === 0)
|
|
62
|
+
owners.delete(options.owner);
|
|
63
|
+
try {
|
|
64
|
+
instance.callbackId.value = "";
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
if (options.owner) {
|
|
70
|
+
let owned = owners.get(options.owner);
|
|
71
|
+
if (!owned) {
|
|
72
|
+
owned = new Set();
|
|
73
|
+
owners.set(options.owner, owned);
|
|
74
|
+
}
|
|
75
|
+
owned.add(handle.dispose);
|
|
76
|
+
}
|
|
77
|
+
return handle;
|
|
78
|
+
}
|
|
79
|
+
export function disposeJavaListenerOwner(owner) {
|
|
80
|
+
const owned = owners.get(owner);
|
|
81
|
+
if (!owned)
|
|
82
|
+
return;
|
|
83
|
+
owners.delete(owner);
|
|
84
|
+
for (const dispose of Array.from(owned))
|
|
85
|
+
dispose();
|
|
86
|
+
}
|
|
87
|
+
export function getJavaListenerDiagnostics() {
|
|
88
|
+
let callbacks = 0;
|
|
89
|
+
for (const pool of pools.values())
|
|
90
|
+
callbacks += pool.callbacks.size;
|
|
91
|
+
return { pools: pools.size, callbacks };
|
|
92
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface JavaRuntime {
|
|
2
|
+
available?: boolean;
|
|
3
|
+
use(name: string): any;
|
|
4
|
+
cast?(value: any, klass: any): any;
|
|
5
|
+
perform?(callback: () => void): void;
|
|
6
|
+
performNow?(callback: () => void): void;
|
|
7
|
+
scheduleOnMainThread?(callback: () => void): void;
|
|
8
|
+
}
|
|
9
|
+
export declare class JavaBridgeCompatibilityError extends Error {
|
|
10
|
+
constructor(missingCapabilities: string[]);
|
|
11
|
+
}
|
|
12
|
+
export declare function assertJavaBridgeCompatible(java: Partial<JavaRuntime> | null | undefined): asserts java is JavaRuntime;
|
|
13
|
+
export declare function waitForJavaAvailable(java: JavaRuntime, timeoutMs?: number, intervalMs?: number): Promise<void>;
|
|
14
|
+
export declare function runWhenJavaReady<T>(java: JavaRuntime, operation: () => T, timeoutMs?: number, label?: string): Promise<T>;
|
|
15
|
+
export declare function scheduleOnMainThread<T>(java: JavaRuntime, operation: () => T, timeoutMs?: number, label?: string): Promise<T>;
|
|
16
|
+
export declare function getApplicationContext(java: JavaRuntime): any;
|
|
17
|
+
export declare function waitForApplicationContext(java: JavaRuntime, timeoutMs?: number, intervalMs?: number): Promise<any>;
|
|
18
|
+
export declare function getWindowManager(java: JavaRuntime, context: any): any;
|
|
19
|
+
export declare function readDisplaySize(context: any, windowManager: any): {
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function readDisplayDensity(context: any): number;
|
|
24
|
+
export declare function getOverlayWindowType(layoutParams: any): number;
|
|
25
|
+
export declare function ensureWindowNotFocusable(params: any, layoutParams: any): boolean;
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
export class JavaBridgeCompatibilityError extends Error {
|
|
2
|
+
constructor(missingCapabilities) {
|
|
3
|
+
super(`The selected frida-java-bridge is missing required capabilities: ${missingCapabilities.join(", ")}`);
|
|
4
|
+
this.name = "JavaBridgeCompatibilityError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export function assertJavaBridgeCompatible(java) {
|
|
8
|
+
const missing = [];
|
|
9
|
+
if (!java || typeof java.use !== "function")
|
|
10
|
+
missing.push("use");
|
|
11
|
+
if (!java
|
|
12
|
+
|| (typeof java.performNow !== "function" && typeof java.perform !== "function")) {
|
|
13
|
+
missing.push("performNow or perform");
|
|
14
|
+
}
|
|
15
|
+
if (!java || typeof java.scheduleOnMainThread !== "function") {
|
|
16
|
+
missing.push("scheduleOnMainThread");
|
|
17
|
+
}
|
|
18
|
+
if (missing.length > 0)
|
|
19
|
+
throw new JavaBridgeCompatibilityError(missing);
|
|
20
|
+
}
|
|
21
|
+
function performImmediately(java, callback) {
|
|
22
|
+
const performNow = java.performNow;
|
|
23
|
+
if (performNow) {
|
|
24
|
+
performNow.call(java, callback);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const perform = java.perform;
|
|
28
|
+
if (perform)
|
|
29
|
+
perform.call(java, callback);
|
|
30
|
+
else
|
|
31
|
+
callback();
|
|
32
|
+
}
|
|
33
|
+
export function waitForJavaAvailable(java, timeoutMs = 30000, intervalMs = 50) {
|
|
34
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
35
|
+
return Promise.reject(new RangeError("timeoutMs must be greater than zero"));
|
|
36
|
+
}
|
|
37
|
+
const deadline = Date.now() + timeoutMs;
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
let timer = null;
|
|
40
|
+
const check = () => {
|
|
41
|
+
try {
|
|
42
|
+
if (java.available !== false) {
|
|
43
|
+
if (timer)
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
resolve();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
if (Date.now() >= deadline) {
|
|
51
|
+
reject(new Error("Timed out waiting for Frida Java Bridge"));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
timer = setTimeout(check, intervalMs);
|
|
55
|
+
};
|
|
56
|
+
check();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function describeError(error) {
|
|
60
|
+
return error instanceof Error ? error.message : String(error);
|
|
61
|
+
}
|
|
62
|
+
export function runWhenJavaReady(java, operation, timeoutMs = 30000, label = "Java operation") {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
let settled = false;
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
if (settled)
|
|
67
|
+
return;
|
|
68
|
+
settled = true;
|
|
69
|
+
reject(new Error(`Timed out waiting for ${label}`));
|
|
70
|
+
}, timeoutMs);
|
|
71
|
+
const finish = (callback) => {
|
|
72
|
+
if (settled)
|
|
73
|
+
return;
|
|
74
|
+
settled = true;
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
callback();
|
|
77
|
+
};
|
|
78
|
+
const run = () => {
|
|
79
|
+
try {
|
|
80
|
+
const value = operation();
|
|
81
|
+
finish(() => resolve(value));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
finish(() => reject(error));
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
performImmediately(java, run);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
finish(() => reject(error));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
export function scheduleOnMainThread(java, operation, timeoutMs = 30000, label = "Android main thread") {
|
|
96
|
+
if (!java.scheduleOnMainThread) {
|
|
97
|
+
return Promise.reject(new Error("Java.scheduleOnMainThread is unavailable"));
|
|
98
|
+
}
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
let settled = false;
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
if (settled)
|
|
103
|
+
return;
|
|
104
|
+
settled = true;
|
|
105
|
+
reject(new Error(`Timed out waiting for Android main thread: ${label}`));
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
const finish = (callback) => {
|
|
108
|
+
if (settled)
|
|
109
|
+
return;
|
|
110
|
+
settled = true;
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
callback();
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
java.scheduleOnMainThread(() => {
|
|
116
|
+
try {
|
|
117
|
+
const value = operation();
|
|
118
|
+
finish(() => resolve(value));
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
finish(() => reject(error));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
finish(() => reject(error));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function readNumber(value) {
|
|
131
|
+
const number = Number(value?.value ?? value);
|
|
132
|
+
return Number.isFinite(number) ? number : Number.NaN;
|
|
133
|
+
}
|
|
134
|
+
function tryCall(callback) {
|
|
135
|
+
try {
|
|
136
|
+
return callback() ?? null;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function getApplication(java) {
|
|
143
|
+
const activityThread = tryCall(() => java.use("android.app.ActivityThread"));
|
|
144
|
+
const current = activityThread
|
|
145
|
+
? tryCall(() => activityThread.currentApplication())
|
|
146
|
+
: null;
|
|
147
|
+
if (current)
|
|
148
|
+
return current;
|
|
149
|
+
const appGlobals = tryCall(() => java.use("android.app.AppGlobals"));
|
|
150
|
+
const initial = appGlobals
|
|
151
|
+
? tryCall(() => appGlobals.getInitialApplication())
|
|
152
|
+
: null;
|
|
153
|
+
if (initial)
|
|
154
|
+
return initial;
|
|
155
|
+
const thread = activityThread
|
|
156
|
+
? tryCall(() => activityThread.currentActivityThread())
|
|
157
|
+
: null;
|
|
158
|
+
return thread ? tryCall(() => thread.getApplication()) : null;
|
|
159
|
+
}
|
|
160
|
+
export function getApplicationContext(java) {
|
|
161
|
+
const application = getApplication(java);
|
|
162
|
+
if (!application) {
|
|
163
|
+
throw new Error("Android Application is not ready. Call FloatMenu.waitForReady() before constructing the menu.");
|
|
164
|
+
}
|
|
165
|
+
const context = tryCall(() => application.getApplicationContext());
|
|
166
|
+
return context || application;
|
|
167
|
+
}
|
|
168
|
+
export function waitForApplicationContext(java, timeoutMs = 30000, intervalMs = 100) {
|
|
169
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
170
|
+
return Promise.reject(new RangeError("timeoutMs must be greater than zero"));
|
|
171
|
+
}
|
|
172
|
+
const deadline = Date.now() + timeoutMs;
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
let settled = false;
|
|
175
|
+
let lastError = null;
|
|
176
|
+
let retryTimer = null;
|
|
177
|
+
const hardTimeout = setTimeout(() => {
|
|
178
|
+
if (settled)
|
|
179
|
+
return;
|
|
180
|
+
settled = true;
|
|
181
|
+
if (retryTimer)
|
|
182
|
+
clearTimeout(retryTimer);
|
|
183
|
+
const suffix = lastError == null ? "" : `: ${describeError(lastError)}`;
|
|
184
|
+
reject(new Error(`Timed out waiting for Android Application context${suffix}`));
|
|
185
|
+
}, timeoutMs);
|
|
186
|
+
const finish = (callback) => {
|
|
187
|
+
if (settled)
|
|
188
|
+
return;
|
|
189
|
+
settled = true;
|
|
190
|
+
clearTimeout(hardTimeout);
|
|
191
|
+
if (retryTimer)
|
|
192
|
+
clearTimeout(retryTimer);
|
|
193
|
+
callback();
|
|
194
|
+
};
|
|
195
|
+
const check = () => {
|
|
196
|
+
const run = () => {
|
|
197
|
+
try {
|
|
198
|
+
const context = getApplicationContext(java);
|
|
199
|
+
finish(() => resolve(context));
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
lastError = error;
|
|
203
|
+
if (Date.now() >= deadline) {
|
|
204
|
+
finish(() => reject(new Error(`Timed out waiting for Android Application context: ${describeError(error)}`)));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
retryTimer = setTimeout(check, intervalMs);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
try {
|
|
211
|
+
performImmediately(java, run);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
lastError = error;
|
|
215
|
+
if (Date.now() >= deadline) {
|
|
216
|
+
finish(() => reject(new Error(`Timed out waiting for Android Application context: ${describeError(error)}`)));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
retryTimer = setTimeout(check, intervalMs);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
check();
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
function hasWindowManagerMethods(service) {
|
|
226
|
+
return service != null
|
|
227
|
+
&& typeof service.addView === "function"
|
|
228
|
+
&& typeof service.updateViewLayout === "function"
|
|
229
|
+
&& typeof service.removeView === "function";
|
|
230
|
+
}
|
|
231
|
+
export function getWindowManager(java, context) {
|
|
232
|
+
const service = tryCall(() => context.getSystemService("window"));
|
|
233
|
+
if (!service)
|
|
234
|
+
throw new Error("Android WindowManager service is unavailable");
|
|
235
|
+
if (java.cast) {
|
|
236
|
+
for (const className of ["android.view.WindowManager", "android.view.ViewManager"]) {
|
|
237
|
+
const klass = tryCall(() => java.use(className));
|
|
238
|
+
const casted = klass ? tryCall(() => java.cast(service, klass)) : null;
|
|
239
|
+
if (hasWindowManagerMethods(casted))
|
|
240
|
+
return casted;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (hasWindowManagerMethods(service))
|
|
244
|
+
return service;
|
|
245
|
+
throw new Error("Android WindowManager service cannot be used as a ViewManager");
|
|
246
|
+
}
|
|
247
|
+
function readSize(metrics) {
|
|
248
|
+
const width = readNumber(metrics?.widthPixels);
|
|
249
|
+
const height = readNumber(metrics?.heightPixels);
|
|
250
|
+
if (width <= 0 || height <= 0 || width > 32768 || height > 32768)
|
|
251
|
+
return null;
|
|
252
|
+
return { width: Math.round(width), height: Math.round(height) };
|
|
253
|
+
}
|
|
254
|
+
export function readDisplaySize(context, windowManager) {
|
|
255
|
+
const metrics = tryCall(() => context.getResources().getDisplayMetrics());
|
|
256
|
+
const resourceSize = readSize(metrics);
|
|
257
|
+
if (resourceSize)
|
|
258
|
+
return resourceSize;
|
|
259
|
+
if (metrics) {
|
|
260
|
+
const display = tryCall(() => windowManager.getDefaultDisplay());
|
|
261
|
+
if (display) {
|
|
262
|
+
tryCall(() => display.getRealMetrics(metrics));
|
|
263
|
+
const realSize = readSize(metrics);
|
|
264
|
+
if (realSize)
|
|
265
|
+
return realSize;
|
|
266
|
+
tryCall(() => display.getMetrics(metrics));
|
|
267
|
+
const displaySize = readSize(metrics);
|
|
268
|
+
if (displaySize)
|
|
269
|
+
return displaySize;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
throw new Error("Android display metrics are invalid or unavailable");
|
|
273
|
+
}
|
|
274
|
+
export function readDisplayDensity(context) {
|
|
275
|
+
const metrics = tryCall(() => context.getResources().getDisplayMetrics());
|
|
276
|
+
const density = readNumber(metrics?.density);
|
|
277
|
+
return density >= 0.25 && density <= 8 ? density : 1;
|
|
278
|
+
}
|
|
279
|
+
export function getOverlayWindowType(layoutParams) {
|
|
280
|
+
for (const fieldName of [
|
|
281
|
+
"TYPE_APPLICATION_OVERLAY",
|
|
282
|
+
"TYPE_PHONE",
|
|
283
|
+
"TYPE_SYSTEM_ALERT",
|
|
284
|
+
]) {
|
|
285
|
+
const type = readNumber(layoutParams?.[fieldName]);
|
|
286
|
+
if (Number.isInteger(type) && type > 0)
|
|
287
|
+
return type;
|
|
288
|
+
}
|
|
289
|
+
throw new Error("No supported Android overlay window type is available");
|
|
290
|
+
}
|
|
291
|
+
export function ensureWindowNotFocusable(params, layoutParams) {
|
|
292
|
+
if (!params)
|
|
293
|
+
throw new Error("Window layout params are unavailable");
|
|
294
|
+
const flag = readNumber(layoutParams?.FLAG_NOT_FOCUSABLE);
|
|
295
|
+
if (!Number.isInteger(flag) || flag <= 0) {
|
|
296
|
+
throw new Error("FLAG_NOT_FOCUSABLE is unavailable");
|
|
297
|
+
}
|
|
298
|
+
const current = readNumber(params.flags);
|
|
299
|
+
const normalizedCurrent = Number.isInteger(current) ? current : 0;
|
|
300
|
+
const next = normalizedCurrent | flag;
|
|
301
|
+
if (next === normalizedCurrent)
|
|
302
|
+
return false;
|
|
303
|
+
if (params.flags && typeof params.flags === "object" && "value" in params.flags) {
|
|
304
|
+
params.flags.value = next;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
params.flags = next;
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
}
|
package/lib/api.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const API: Record<string, any>;
|