@bobfrankston/msgapidefs 0.1.28 → 0.1.30
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 +121 -6
- package/msgapi-plan.md +123 -47
- package/msgapidefs.ts +94 -236
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# @bobfrankston/msgapidefs
|
|
2
2
|
|
|
3
|
-
TypeScript definitions for the **msgapi** JavaScript API used in msgview and msger.
|
|
3
|
+
TypeScript definitions for the **msgapi** JavaScript API used in msgview and msger. See the Typescript definitions in
|
|
4
|
+
[msgdpidefs.ts](msgapidefs.ts) for more info
|
|
4
5
|
|
|
5
6
|
## Installation
|
|
6
7
|
|
|
@@ -34,8 +35,69 @@ localStorage.setItem('key', 'value'); // Instead of msgapi.saveData()
|
|
|
34
35
|
window.close(); // Instead of msgapi.close() if not passing result
|
|
35
36
|
```
|
|
36
37
|
|
|
38
|
+
## Implementation Status
|
|
39
|
+
|
|
40
|
+
✅ = Implemented | ⚠️ = Partial | ❌ = Not implemented
|
|
41
|
+
|
|
42
|
+
| API | msgview | msger | msga | Notes |
|
|
43
|
+
|-----|:-------:|:-----:|:----:|-------|
|
|
44
|
+
| **Configuration** | | | | |
|
|
45
|
+
| `version` | ❌ | ❌ | ✅ | `window.msgapi.version` string |
|
|
46
|
+
| `setLogging(value?)` | ❌ | ❌ | ✅ | Returns previous state |
|
|
47
|
+
| `setAutoUpdate(value?)` | ❌ | ❌ | ✅ | `'auto'`/`'check'`/`'off'`; msga Android only |
|
|
48
|
+
| **Window Control** | | | | **`window.msgapi.*`** |
|
|
49
|
+
| `toggleFullscreen()` | ❌ | ⚠️ | ✅ | msger: via IPC, not on window.msgapi |
|
|
50
|
+
| `setFullscreen(bool)` | ✅ | ⚠️ | ✅ | msger: via IPC, not on window.msgapi |
|
|
51
|
+
| `minimize()` | ✅ | ⚠️ | ✅ | msga: Windows only; msger: via IPC |
|
|
52
|
+
| `maximize()` | ✅ | ⚠️ | ✅ | msga: Windows only; msger: via IPC |
|
|
53
|
+
| `setSize(w, h)` | ✅ | ⚠️ | ✅ | msga: Windows only; msger: via IPC |
|
|
54
|
+
| `setPosition(x, y)` | ✅ | ⚠️ | ✅ | msga: Windows only; msger: via IPC |
|
|
55
|
+
| `setAlwaysOnTop(bool)` | ✅ | ⚠️ | ❌ | msger: via IPC |
|
|
56
|
+
| `close(result?)` | ✅ | ✅ | ✅ | msger: on window.msger |
|
|
57
|
+
| `navigate(url)` | ❌ | ❌ | ✅ | msga only |
|
|
58
|
+
| `reload()` | ❌ | ❌ | ✅ | msga only |
|
|
59
|
+
| **File System** | | | | **`window.msgapi.fs.*`** |
|
|
60
|
+
| `fs.selectFile()` | ✅ | ✅ | ⚠️ | msga: C# exists, not wired to JS |
|
|
61
|
+
| `fs.selectFiles()` | ✅ | ✅ | ⚠️ | msga: C# exists, not wired to JS |
|
|
62
|
+
| `fs.saveFileAs()` | ✅ | ✅ | ❌ | |
|
|
63
|
+
| `fs.selectFolder()` | ✅ | ✅ | ❌ | |
|
|
64
|
+
| `fs.read(path)` | ✅ | ✅ | ❌ | Requires allowFs flag |
|
|
65
|
+
| `fs.write(path, content)` | ✅ | ✅ | ❌ | Requires allowFs flag |
|
|
66
|
+
| `fs.list(path)` | ✅ | ✅ | ❌ | Requires allowFs flag |
|
|
67
|
+
| `fs.exists(path)` | ✅ | ✅ | ❌ | Requires allowFs flag |
|
|
68
|
+
| `fs.delete(path)` | ✅ | ✅ | ❌ | Requires allowFs flag |
|
|
69
|
+
| **UDP Networking** | | | | **`window.msgapi.udp.*`** |
|
|
70
|
+
| `udp.send()` | ❌ | ❌ | ✅ | msga: C# UdpClient |
|
|
71
|
+
| `udp.listen()` | ❌ | ❌ | ✅ | msga: background receive loop |
|
|
72
|
+
| `udp.sendReceive()` | ❌ | ❌ | ✅ | msga: send + wait with timeout |
|
|
73
|
+
| `udp.broadcast()` | ❌ | ❌ | ✅ | msga: EnableBroadcast=true |
|
|
74
|
+
|
|
75
|
+
> **msger note:** Window control functionality exists in Rust but is exposed via `window.msger` + IPC actions (`window.ipc.postMessage({_action: ...})`), not as `window.msgapi` methods. Alignment to `window.msgapi` is needed.
|
|
76
|
+
|
|
77
|
+
[httpudp-client](../../homecontrol/utils/udp/httpudp-client/README.md) auto-detects `window.msgapi?.udp` and uses native UDP when available, falling back to the httpudp WebSocket proxy otherwise.
|
|
78
|
+
|
|
79
|
+
See [`msgapi-plan.md`](./msgapi-plan.md) for the full implementation plan and roadmap.
|
|
80
|
+
|
|
37
81
|
## API Reference
|
|
38
82
|
|
|
83
|
+
### Configuration
|
|
84
|
+
|
|
85
|
+
- `version` - Host app version string (e.g., `"1.2.0"`)
|
|
86
|
+
- `setLogging(value?)` - Control debug logging. Returns previous state as string.
|
|
87
|
+
- `'msga'` - C# host logging only (logit-msga)
|
|
88
|
+
- `'msgapi'` - JS bridge logging only (logit-msgapi)
|
|
89
|
+
- `'on'` - Both streams
|
|
90
|
+
- `'off'` / `'none'` - All off
|
|
91
|
+
- `null` / `''` - Query current state without changing
|
|
92
|
+
- Returns: `'msga'`, `'msgapi'`, `'msga,msgapi'`, or `'none'`
|
|
93
|
+
- `setAutoUpdate(value?)` - Control APK auto-update behavior. Returns previous state as string. msga Android only.
|
|
94
|
+
- `'auto'` - Check versions.json, download + install silently (no prompt)
|
|
95
|
+
- `'check'` - Check versions.json, prompt "Install now?" before downloading
|
|
96
|
+
- `'off'` - No update checking
|
|
97
|
+
- `null` / `undefined` - Query current state without changing
|
|
98
|
+
- Default: `'auto'` (configurable via `AUTO_UPDATE` in msga.env)
|
|
99
|
+
- Checks on startup then every `UPDATE_INTERVAL_MINUTES` (default 60)
|
|
100
|
+
|
|
39
101
|
### Window Control
|
|
40
102
|
|
|
41
103
|
- `toggleFullscreen()` - Toggle fullscreen mode
|
|
@@ -49,15 +111,29 @@ window.close(); // Instead of msgapi.close() if not passing result
|
|
|
49
111
|
|
|
50
112
|
### File System
|
|
51
113
|
|
|
114
|
+
File dialogs (user picks interactively — safe without special permissions):
|
|
52
115
|
- `fs.selectFile(options?)` - Open file picker
|
|
53
116
|
- `fs.selectFiles(options?)` - Open multi-file picker
|
|
54
117
|
- `fs.saveFileAs(content, filename?, options?)` - Save file dialog
|
|
55
118
|
- `fs.selectFolder(options?)` - Folder picker
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
- `fs.
|
|
59
|
-
- `fs.
|
|
60
|
-
- `fs.
|
|
119
|
+
|
|
120
|
+
Direct path operations (give the webview full filesystem access):
|
|
121
|
+
- `fs.read(path)` - Read file
|
|
122
|
+
- `fs.write(path, content)` - Write file
|
|
123
|
+
- `fs.list(path)` - List directory
|
|
124
|
+
- `fs.exists(path)` - Check if file exists
|
|
125
|
+
- `fs.delete(path)` - Delete file
|
|
126
|
+
|
|
127
|
+
> **`allowFs` flag:** Direct path operations are intended to require an explicit `--allowFs` CLI flag (e.g., `msger -url "..." --allowFs`) since they grant unrestricted filesystem access. This flag is documented in the interface but **not yet enforced** — the methods currently work without it in both msger and msgview. Enforcement is planned.
|
|
128
|
+
|
|
129
|
+
### UDP Networking
|
|
130
|
+
|
|
131
|
+
Native UDP socket access — no WebSocket proxy needed when running inside a msgapi host.
|
|
132
|
+
|
|
133
|
+
- `udp.send(host, port, data)` - Send UDP packet to specific host
|
|
134
|
+
- `udp.listen(port, callback)` - Listen for incoming UDP packets (returns `{close()}` handle)
|
|
135
|
+
- `udp.sendReceive(host, port, data, timeoutMs?)` - Send and wait for response
|
|
136
|
+
- `udp.broadcast(port, data)` - Broadcast to local network
|
|
61
137
|
|
|
62
138
|
## Examples
|
|
63
139
|
|
|
@@ -86,10 +162,49 @@ The samples demonstrate:
|
|
|
86
162
|
- And more!
|
|
87
163
|
|
|
88
164
|
|
|
165
|
+
## Cross-Platform Implementation
|
|
166
|
+
|
|
167
|
+
`msgapidefs` defines the TypeScript interface only. Each host implements the `window.msgapi` bridge differently:
|
|
168
|
+
|
|
169
|
+
### msgview (Electron)
|
|
170
|
+
- **Bridge:** Electron `contextBridge.exposeInMainWorld('msgapi', ...)` in `preload/preload.ts`
|
|
171
|
+
- **Mechanism:** `ipcRenderer.invoke()` calls to main process IPC handlers
|
|
172
|
+
- **Window control:** Electron `BrowserWindow` API
|
|
173
|
+
- **File system:** Node.js `fs` module via IPC, file dialogs via Electron `dialog`
|
|
174
|
+
|
|
175
|
+
### msger (Rust/wry)
|
|
176
|
+
- **Bridge:** JavaScript injected via wry `with_initialization_script()` in `msger-native/src/main.rs`
|
|
177
|
+
- **JS API:** `msger-native/src/msger-api.js` creates `window.msger` object
|
|
178
|
+
- **Mechanism:** `window.ipc.postMessage(JSON.stringify(...))` to Rust IPC handler
|
|
179
|
+
- **Note:** Currently exposes `window.msger` (not `window.msgapi`) - alignment needed
|
|
180
|
+
- **Limitations:** wry 0.47.2 has IPC issues; `close()` uses `window.close()` workaround
|
|
181
|
+
|
|
182
|
+
### msga (.NET MAUI)
|
|
183
|
+
- **Bridge:** JavaScript injected via `WebViewControl.EvaluateJavaScriptAsync()` in `MainPage.xaml.cs`
|
|
184
|
+
- **JS API:** `GetMsgApiScript()` creates `window.msgapi` object inline
|
|
185
|
+
- **Mechanism:** `msgapi://` URL interception — JS creates hidden iframes, C# intercepts in `OnNavigating`
|
|
186
|
+
- **Async:** Promise callbacks via `callNativeAsync()` → `window._msgapiResolve()` / `_msgapiReject()`
|
|
187
|
+
- **Window control:** Platform-conditional (`#if WINDOWS` uses WinUI AppWindow APIs)
|
|
188
|
+
- **File system:** MAUI `FilePicker`, Windows-specific `FileSavePicker`/`FolderPicker`
|
|
189
|
+
- **Platforms:** Android (primary target), Windows (development/testing)
|
|
190
|
+
|
|
191
|
+
### Adding New Sub-Objects
|
|
192
|
+
|
|
193
|
+
Each capability is an optional sub-object on `MsgAPI` (e.g., `fs?`, `udp?`). To add a new one:
|
|
194
|
+
1. Define the interface in [`msgapidefs.ts`](./msgapidefs.ts) as an optional sub-object on `MsgAPI`
|
|
195
|
+
2. Implement the native side in each host:
|
|
196
|
+
- **msgview:** Add IPC handlers in `main.ts`, expose in `preload/preload.ts`
|
|
197
|
+
- **msger:** Add Rust handler in `main.rs`, expose in `msger-api.js`
|
|
198
|
+
- **msga:** Add C# methods + JS injection in `GetMsgApiScript()` (see [msga readme](../msga/readme.md))
|
|
199
|
+
3. Browser code uses optional chaining: `window.msgapi?.newFeature?.method()` — works everywhere, undefined when unavailable
|
|
200
|
+
|
|
201
|
+
See [`msgapi-plan.md`](./msgapi-plan.md) for the full feature matrix and implementation status across all three hosts.
|
|
202
|
+
|
|
89
203
|
## Related Packages
|
|
90
204
|
|
|
91
205
|
- [@bobfrankston/msgview](https://www.npmjs.com/package/@bobfrankston/msgview) - Electron-based message box (slower, works on Pi)
|
|
92
206
|
- [@bobfrankston/msger](https://www.npmjs.com/package/@bobfrankston/msger) - Rust/wry-based message box (fast, lightweight)
|
|
207
|
+
- [msga](https://github.com/BobFrankston/msga) - .NET MAUI Android/Windows wrapper app
|
|
93
208
|
|
|
94
209
|
## License
|
|
95
210
|
|
package/msgapi-plan.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# msgapi Implementation Plan
|
|
2
2
|
|
|
3
|
+
> **This is the authoritative plan for msgapi.** Other TODOs (md/todo.md) reference here for msgapi-related items.
|
|
3
4
|
Shared planning document for msgapi across implementations:
|
|
4
|
-
- **TypeScript**: msgapidefs (this repo) - types/definitions
|
|
5
|
-
- **TypeScript/Electron**: msgview
|
|
6
|
-
- **Rust/wry**: msger
|
|
7
|
-
- **C#/.NET MAUI**: msga (
|
|
5
|
+
- **TypeScript**: [msgapidefs](../msgapidefs/) (this repo) - types/definitions ([README](../msgapidefs/README.md))
|
|
6
|
+
- **TypeScript/Electron**: [msgview](../msgview/)
|
|
7
|
+
- **Rust/wry**: [msger](../msger/)
|
|
8
|
+
- **C#/.NET MAUI**: [msga](../msga/) ([readme](../msga/readme.md))
|
|
8
9
|
|
|
9
10
|
## Current Status
|
|
10
11
|
|
|
@@ -13,14 +14,16 @@ Shared planning document for msgapi across implementations:
|
|
|
13
14
|
| API | msgview | msger | msga | Notes |
|
|
14
15
|
|-----|---------|-------|------|-------|
|
|
15
16
|
| **Window Control** |
|
|
16
|
-
| toggleFullscreen() | ✅ | ✅ |
|
|
17
|
-
| setFullscreen(bool) | ✅ | ✅ |
|
|
18
|
-
| minimize() | ✅ | ✅ |
|
|
19
|
-
| maximize() | ✅ | ✅ |
|
|
20
|
-
| setSize(w,h) | ✅ | ✅ |
|
|
21
|
-
| setPosition(x,y) | ✅ | ✅ |
|
|
17
|
+
| toggleFullscreen() | ✅ | ✅ | ✅ | msga: fire-and-forget JS→C# |
|
|
18
|
+
| setFullscreen(bool) | ✅ | ✅ | ✅ | |
|
|
19
|
+
| minimize() | ✅ | ✅ | ✅ | msga: Windows only |
|
|
20
|
+
| maximize() | ✅ | ✅ | ✅ | msga: Windows only |
|
|
21
|
+
| setSize(w,h) | ✅ | ✅ | ✅ | msga: Windows only |
|
|
22
|
+
| setPosition(x,y) | ✅ | ✅ | ✅ | msga: Windows only |
|
|
22
23
|
| setAlwaysOnTop(bool) | ✅ | ✅ | ❌ | |
|
|
23
|
-
| close(result?) | ✅ | ✅ |
|
|
24
|
+
| close(result?) | ✅ | ✅ | ✅ | |
|
|
25
|
+
| navigate(url) | ❌ | ❌ | ✅ | msga only |
|
|
26
|
+
| reload() | ❌ | ❌ | ✅ | msga only |
|
|
24
27
|
| **File System** |
|
|
25
28
|
| fs.selectFile() | ✅ | ✅ | ⚠️ | msga: C# exists, not wired |
|
|
26
29
|
| fs.selectFiles() | ✅ | ✅ | ⚠️ | msga: C# exists, not wired |
|
|
@@ -31,54 +34,109 @@ Shared planning document for msgapi across implementations:
|
|
|
31
34
|
| fs.list(path) | ✅ | ✅ | ❌ | Requires allowFs flag |
|
|
32
35
|
| fs.exists(path) | ✅ | ✅ | ❌ | Requires allowFs flag |
|
|
33
36
|
| fs.delete(path) | ✅ | ✅ | ❌ | Requires allowFs flag |
|
|
37
|
+
| **UDP** |
|
|
38
|
+
| udp.send() | ❌ | ❌ | ✅ | msga: C# UdpClient |
|
|
39
|
+
| udp.listen() | ❌ | ❌ | ✅ | msga: background receive loop |
|
|
40
|
+
| udp.sendReceive() | ❌ | ❌ | ✅ | msga: send + wait with timeout |
|
|
41
|
+
| udp.broadcast() | ❌ | ❌ | ✅ | msga: EnableBroadcast=true |
|
|
42
|
+
| **Configuration** |
|
|
43
|
+
| setLogging(value?) | ❌ | ❌ | ✅ | Returns old state; 'msga'/'msgapi'/'on'/'off'/null |
|
|
44
|
+
| setAutoUpdate(value?) | ❌ | ❌ | ✅ | 'auto'/'check'/'off'; defaults to 'off' (web app owns policy) |
|
|
45
|
+
| installUpdate(url) | ❌ | ❌ | ✅ | Download + install APK; Android only. Web app calls this. |
|
|
46
|
+
| getNetworkInfo() | ❌ | ❌ | ✅ | Fresh local IP + subnet mask; used by httpudp-client isAddressReachable() |
|
|
47
|
+
| info | ❌ | ❌ | ✅ | `MsgHostInfo`: host, version, platform, updateUrl |
|
|
48
|
+
| updateUrl | ❌ | ❌ | ✅ | URL to versions.json — install source for update checks |
|
|
49
|
+
| version | ❌ | ❌ | ✅ | `window.msgapi.version` = app version string |
|
|
34
50
|
|
|
35
51
|
Legend: ✅ Implemented | ⚠️ Partial/Not wired | ❌ Not implemented
|
|
36
52
|
|
|
37
|
-
### msga
|
|
53
|
+
### msga Logging
|
|
38
54
|
|
|
39
|
-
|
|
40
|
-
- JavaScript injection exists in MainPage.xaml.cs
|
|
41
|
-
- C# handler methods exist in Services/MsgApiBridge.cs
|
|
42
|
-
- Missing: WebView JavaScript-to-C# message handler wiring
|
|
55
|
+
Two independent log streams, both via `https://rmf39.aaz.lt/logit/`:
|
|
43
56
|
|
|
44
|
-
|
|
57
|
+
| Stream | Log name | Controlled by | Content |
|
|
58
|
+
|--------|----------|---------------|---------|
|
|
59
|
+
| **C# (MsgALog)** | `logit-msga` | `LOGGING=true` in msga.env | OnNavigated, msgapi:// dispatch, SendAsync, UDP recv |
|
|
60
|
+
| **JS (_logit)** | `logit-msgapi` | `JS_LOGGING=true` in msga.env | msgapi inject, msgapi object creation |
|
|
45
61
|
|
|
46
|
-
|
|
62
|
+
**Startup defaults** are set in `msga.env`. At **runtime**:
|
|
63
|
+
```javascript
|
|
64
|
+
msgapi.setLogging() // → 'none' (query current, no change)
|
|
65
|
+
msgapi.setLogging('msga') // → 'none' (old); enables C# only
|
|
66
|
+
msgapi.setLogging('msgapi') // → 'msga' (old); enables JS only
|
|
67
|
+
msgapi.setLogging('on') // → 'msgapi' (old); enables both
|
|
68
|
+
msgapi.setLogging('off') // → 'msga,msgapi' (old); disables both
|
|
69
|
+
```
|
|
70
|
+
Always returns previous state as string: `'msga'`, `'msgapi'`, `'msga,msgapi'`, or `'none'`.
|
|
71
|
+
|
|
72
|
+
### msga Implementation Status
|
|
47
73
|
|
|
48
|
-
|
|
74
|
+
msga has the most complete msgapi.udp implementation. The JS bridge is fully wired:
|
|
75
|
+
- `GetMsgApiScript()` in MainPage.xaml.cs injects `window.msgapi` with all sub-objects
|
|
76
|
+
- Window control: fire-and-forget calls via `MsgABridge.invokeMethod()`
|
|
77
|
+
- UDP: async Promise-based calls via `msgapi://udp/*` URL routing → `UdpService.cs`
|
|
78
|
+
- Data transport: base64 encoding for binary data over the bridge
|
|
79
|
+
- Listener callbacks: pushed via `window._msgapiUdpReceive()` from C#
|
|
49
80
|
|
|
50
|
-
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## UDP Interface
|
|
84
|
+
|
|
85
|
+
Defined in [`msgapidefs.ts`](./msgapidefs.ts) and implemented in [msga](../msga/). Types available via `@bobfrankston/msgapidefs`.
|
|
51
86
|
|
|
52
87
|
```typescript
|
|
53
88
|
interface MsgAPI {
|
|
54
|
-
udp
|
|
55
|
-
// Send UDP datagram
|
|
89
|
+
udp?: {
|
|
56
90
|
send(host: string, port: number, data: string | Uint8Array): Promise<void>;
|
|
57
|
-
|
|
58
|
-
// Listen for UDP datagrams on a port
|
|
59
91
|
listen(port: number, callback: (data: Uint8Array, sender: {host: string, port: number}) => void): Promise<{close: () => void}>;
|
|
60
|
-
|
|
61
|
-
// Send and wait for response (with timeout)
|
|
62
92
|
sendReceive(host: string, port: number, data: string | Uint8Array, timeoutMs?: number): Promise<Uint8Array>;
|
|
63
|
-
|
|
64
|
-
// Broadcast to subnet
|
|
65
93
|
broadcast(port: number, data: string | Uint8Array): Promise<void>;
|
|
66
94
|
}
|
|
67
95
|
}
|
|
68
96
|
```
|
|
69
97
|
|
|
70
|
-
**
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
- C#/MAUI (msga): Use System.Net.Sockets.UdpClient
|
|
98
|
+
**Platform implementations**:
|
|
99
|
+
- **msga** (C#/MAUI): ✅ `System.Net.Sockets.UdpClient` via `UdpService.cs`
|
|
100
|
+
- **msgview** (Electron): ❌ Planned — Node.js `dgram` via IPC/preload
|
|
101
|
+
- **msger** (Rust/wry): ❌ Planned — `std::net::UdpSocket` or tokio
|
|
75
102
|
|
|
76
|
-
**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
103
|
+
**httpudp-client integration**: `HttpUdpClient.connect()` detects `window.msgapi?.udp` and uses it directly when available, falling back to the httpudp WebSocket proxy otherwise. Callers never know which path was taken.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Web-App-Driven APK Updates
|
|
108
|
+
|
|
109
|
+
Update policy is owned by the web app (houser), not msga. msga provides the `installUpdate(url)` primitive; the web app decides when/whether to use it.
|
|
110
|
+
|
|
111
|
+
**How it works:**
|
|
112
|
+
1. Web app fetches `versions.json` from `https://rmf39.aaz.lt/msga/versions.json`
|
|
113
|
+
2. Compares remote version with `window.msgapi.version`
|
|
114
|
+
3. If newer, calls `await window.msgapi.installUpdate(apkUrl)` to download + install
|
|
115
|
+
4. Optionally, UDP broadcast can notify about available updates (no polling needed)
|
|
116
|
+
|
|
117
|
+
**`setAutoUpdate` defaults to `'off'`** — the C#-side timer-based auto-update is disabled by default. Web app calls `installUpdate()` directly when it decides an update is needed. `setAutoUpdate('auto')` can still be used to re-enable C#-side silent updates if desired.
|
|
118
|
+
|
|
119
|
+
```javascript
|
|
120
|
+
// Web app update check example (types: MsgVersionInfo, MsgApkInfo from msgapidefs)
|
|
121
|
+
// Versions are strings like "1.5.21" — compare numerically, not lexicographically
|
|
122
|
+
const isNewer = (remote, current) => {
|
|
123
|
+
const c = current.split('.').map(Number);
|
|
124
|
+
return remote.split('.').map(Number).map((v, i) => v - c[i]).reduce((r, d) => r || d, 0) > 0;
|
|
125
|
+
};
|
|
126
|
+
const resp = await fetch(window.msgapi.updateUrl);
|
|
127
|
+
const info = await resp.json(); // MsgVersionInfo
|
|
128
|
+
if (isNewer(info.version, window.msgapi.version)) {
|
|
129
|
+
const apk = info.apks.find(a => a.name === 'houser45');
|
|
130
|
+
const baseUrl = window.msgapi.updateUrl.replace(/\/[^/]*$/, '/');
|
|
131
|
+
await window.msgapi.installUpdate(baseUrl + apk.file);
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Planned Features
|
|
80
138
|
|
|
81
|
-
###
|
|
139
|
+
### 1. Enhanced File System
|
|
82
140
|
|
|
83
141
|
**Use case**: Full file system access for local apps, document management
|
|
84
142
|
|
|
@@ -160,16 +218,16 @@ interface MsgAPI {
|
|
|
160
218
|
|
|
161
219
|
## Implementation Priorities
|
|
162
220
|
|
|
163
|
-
### Phase 1:
|
|
164
|
-
1.
|
|
165
|
-
2.
|
|
166
|
-
3.
|
|
221
|
+
### Phase 1: msga msgapi + UDP ✅ Done
|
|
222
|
+
1. ✅ JavaScript bridge wired in msga (GetMsgApiScript → msgapi:// URL routing)
|
|
223
|
+
2. ✅ Window control working on Android/Windows
|
|
224
|
+
3. ✅ UDP types added to msgapidefs
|
|
225
|
+
4. ✅ UDP fully implemented in msga (UdpService.cs)
|
|
226
|
+
5. ✅ httpudp-client detects msgapi.udp and uses it natively
|
|
167
227
|
|
|
168
|
-
### Phase 2: UDP
|
|
169
|
-
1.
|
|
170
|
-
2.
|
|
171
|
-
3. Port to msgview (Electron)
|
|
172
|
-
4. Port to msga (C#/MAUI)
|
|
228
|
+
### Phase 2: UDP in msger/msgview
|
|
229
|
+
1. Implement in msger (Rust) — `std::net::UdpSocket` or tokio
|
|
230
|
+
2. Port to msgview (Electron) — Node.js `dgram` via IPC/preload
|
|
173
231
|
|
|
174
232
|
### Phase 3: Enhanced File System
|
|
175
233
|
1. Add new fs types to msgapidefs
|
|
@@ -200,6 +258,24 @@ Test categories:
|
|
|
200
258
|
|
|
201
259
|
---
|
|
202
260
|
|
|
261
|
+
## Known Issues (Resolved)
|
|
262
|
+
|
|
263
|
+
### msga: hash navigations killed UDP listener (fixed v1.4.0)
|
|
264
|
+
|
|
265
|
+
MAUI fires OnNavigated for in-page hash changes (#Floor3, #Office, etc.) even though it's a SPA. OnNavigated was disposing UdpService on every such event, killing the listener that httpudp-client set up via msgapi.udp.listen(). Sends still worked (ephemeral sockets) but responses were lost. Symptom: "works once then stops" as the first room switch killed the listener.
|
|
266
|
+
|
|
267
|
+
**Fix:** OnNavigated now compares the base URL (without hash) and skips disposal + re-injection for hash-only changes.
|
|
268
|
+
|
|
269
|
+
### msga: httpudp://start caused stuck loading overlay (fixed v1.5.3)
|
|
270
|
+
|
|
271
|
+
httpudp-client does `location.href = 'httpudp://start'` to try launching the httpudp proxy app on Android. This custom URL scheme triggered MAUI navigation events in unexpected order (OnNavigated before OnNavigating), causing the loading overlay to get stuck and — in some cases — preventing msgapi injection entirely.
|
|
272
|
+
|
|
273
|
+
**Fix (v1.5.3):** OnNavigating and OnNavigated now skip non-http/https URLs via `IsHttpUrl()` check. LinkNavigationWebViewClient blocks non-http schemes (`return true` = "handled") so the Android WebView doesn't try to load them.
|
|
274
|
+
|
|
275
|
+
**Note:** httpudp-client's `ensureHttpUdpProxy()` should NOT fire when running inside msga (msgapi.udp is available natively). If `httpudp://start` navigations appear in logs, it means httpudp-client isn't detecting msgapi.udp — check that `msgapiready` event fires before httpudp-client's `connect()` runs.
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
203
279
|
## Notes
|
|
204
280
|
|
|
205
281
|
- msgapi is injected as `window.msgapi` global
|
package/msgapidefs.ts
CHANGED
|
@@ -1,263 +1,121 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*/
|
|
13
|
-
export interface MsgResult {
|
|
14
|
-
/** Button that was clicked */
|
|
15
|
-
button: string;
|
|
16
|
-
/** Optional input field value */
|
|
17
|
-
value?: string;
|
|
18
|
-
/** Optional form data */
|
|
19
|
-
form?: Record<string, any>;
|
|
20
|
-
/** True if window was closed via close button */
|
|
21
|
-
closed?: boolean;
|
|
22
|
-
/** True if window was dismissed via Escape key */
|
|
23
|
-
dismissed?: boolean;
|
|
24
|
-
/** True if window was closed due to timeout */
|
|
25
|
-
timeout?: boolean;
|
|
1
|
+
// TypeScript definitions for the msgapi JavaScript API
|
|
2
|
+
// Type-safe access to msgapi's window control, file system, and UDP features
|
|
3
|
+
// Used by msgview, msger, and msga. See README.md for docs.
|
|
4
|
+
|
|
5
|
+
export interface MsgResult { // Result object returned when the window closes
|
|
6
|
+
button: string; // Button that was clicked
|
|
7
|
+
value?: string; // Optional input field value
|
|
8
|
+
form?: Record<string, any>; // Optional form data
|
|
9
|
+
closed?: boolean; // True if closed via close button
|
|
10
|
+
dismissed?: boolean; // True if dismissed via Escape key
|
|
11
|
+
timeout?: boolean; // True if closed due to timeout
|
|
26
12
|
}
|
|
27
13
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
/** Full file path */
|
|
35
|
-
path: string;
|
|
36
|
-
/** True if this is a directory */
|
|
37
|
-
isDir: boolean;
|
|
38
|
-
/** File size in bytes (0 for directories) */
|
|
39
|
-
size: number;
|
|
40
|
-
/** Last modified timestamp (ISO 8601 string) */
|
|
41
|
-
modified?: string;
|
|
14
|
+
export interface FileInfo { // File information from file system operations
|
|
15
|
+
name: string; // File name
|
|
16
|
+
path: string; // Full file path
|
|
17
|
+
isDir: boolean; // True if this is a directory
|
|
18
|
+
size: number; // File size in bytes (0 for directories)
|
|
19
|
+
modified?: string; // Last modified timestamp (ISO 8601 string)
|
|
42
20
|
}
|
|
43
21
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
title?: string;
|
|
50
|
-
/** Default filename for save dialogs */
|
|
51
|
-
defaultFilename?: string;
|
|
52
|
-
/** File filters (e.g., [{name: "Text", extensions: ["txt", "md"]}]) */
|
|
53
|
-
filters?: Array<{name: string; extensions: string[]}>;
|
|
54
|
-
/** Default directory to open */
|
|
55
|
-
defaultPath?: string;
|
|
22
|
+
export interface FileDialogOptions { // Options for file selection dialogs
|
|
23
|
+
title?: string; // Dialog title
|
|
24
|
+
defaultFilename?: string; // Default filename for save dialogs
|
|
25
|
+
filters?: Array<{name: string; extensions: string[]}>; // File filters
|
|
26
|
+
defaultPath?: string; // Default directory to open
|
|
56
27
|
}
|
|
57
28
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
/** File name */
|
|
63
|
-
name: string;
|
|
64
|
-
/** Full file path */
|
|
65
|
-
path: string;
|
|
66
|
-
/** File content as string */
|
|
67
|
-
content: string;
|
|
29
|
+
export interface SelectedFile { // Selected file with content
|
|
30
|
+
name: string; // File name
|
|
31
|
+
path: string; // Full file path
|
|
32
|
+
content: string; // File content as string
|
|
68
33
|
}
|
|
69
34
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
export interface
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
35
|
+
export type MsgHost = 'msga' | 'msger' | 'msgview';
|
|
36
|
+
export type MsgPlatform = 'android' | 'windows' | 'linux' | 'macos';
|
|
37
|
+
export type AutoUpdateMode = 'auto' | 'check' | 'off';
|
|
38
|
+
export type LoggingMode = 'msga' | 'msgapi' | 'on' | 'off' | 'none';
|
|
39
|
+
|
|
40
|
+
export interface MsgHostInfo { // Host application identity and capabilities
|
|
41
|
+
host: MsgHost; // Which host app is running
|
|
42
|
+
version: string; // App version (e.g. "1.5.21") — semver string, compare numerically not lexicographically
|
|
43
|
+
platform: MsgPlatform; // Runtime platform
|
|
44
|
+
appName?: string; // APK variant name (e.g. "houser46") — derived from package ID, matches versions.json apks[].name
|
|
45
|
+
updateUrl?: string; // URL to versions.json — install source for update checks (msga only)
|
|
46
|
+
apkUrl?: string; // Direct URL to this APK (e.g. "https://rmf39.aaz.lt/msga/apks/houser46.apk")
|
|
47
|
+
localIp?: string; // Device IP on current network (e.g. "172.20.1.42")
|
|
48
|
+
localMask?: string; // Subnet mask (e.g. "255.255.0.0") — msga uses ip+mask to reject udp.send() when destination is off-network
|
|
49
|
+
}
|
|
79
50
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
51
|
+
export interface MsgApkInfo { // One APK entry in versions.json
|
|
52
|
+
name: string; // App variant name (e.g. "houser", "houser45")
|
|
53
|
+
url: string; // Default URL the APK loads
|
|
54
|
+
description: string; // Human-readable description
|
|
55
|
+
icon: string; // Icon URL
|
|
56
|
+
file: string; // APK file path relative to versions.json (e.g. "apks/houser.apk")
|
|
57
|
+
sizeMB: number; // Approximate size in MB
|
|
58
|
+
built: string; // Build timestamp (ISO 8601)
|
|
59
|
+
default?: boolean; // True if this is the default APK
|
|
60
|
+
}
|
|
86
61
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
*/
|
|
93
|
-
setFullscreen(enabled: boolean): void;
|
|
62
|
+
export interface MsgVersionInfo { // Structure of versions.json
|
|
63
|
+
version: string; // Latest available version (e.g. "1.5.21") — semver string, compare numerically not lexicographically
|
|
64
|
+
buildDate: string; // Build timestamp (ISO 8601)
|
|
65
|
+
apks: MsgApkInfo[]; // Available APK variants
|
|
66
|
+
}
|
|
94
67
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
* @example
|
|
98
|
-
* msgapi.minimize();
|
|
99
|
-
*/
|
|
100
|
-
minimize(): void;
|
|
68
|
+
// msgapi JavaScript API — available via window.msgapi inside msgview/msger/msga
|
|
69
|
+
export interface MsgAPI {
|
|
101
70
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
* msgapi.maximize();
|
|
106
|
-
*/
|
|
107
|
-
maximize(): void;
|
|
71
|
+
version?: string; // Host app version (e.g. "1.3.0") — semver string, compare numerically not lexicographically
|
|
72
|
+
updateUrl?: string; // URL to versions.json — install source for update checks
|
|
73
|
+
info?: MsgHostInfo; // Host identity: host name, version, platform, updateUrl
|
|
108
74
|
|
|
109
|
-
|
|
110
|
-
* Set window size
|
|
111
|
-
* @param width - Window width in pixels
|
|
112
|
-
* @param height - Window height in pixels
|
|
113
|
-
* @example
|
|
114
|
-
* msgapi.setSize(800, 600);
|
|
115
|
-
*/
|
|
116
|
-
setSize(width: number, height: number): void;
|
|
75
|
+
// ── Configuration ────────────────────────────────────────────
|
|
117
76
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
* @example
|
|
123
|
-
* msgapi.setPosition(100, 100);
|
|
124
|
-
*/
|
|
125
|
-
setPosition(x: number, y: number): void;
|
|
77
|
+
setLogging?(value?: LoggingMode): LoggingMode; // null/undefined = query only. Returns previous state.
|
|
78
|
+
setAutoUpdate?(value?: AutoUpdateMode): AutoUpdateMode; // null/undefined = query only. Returns previous state. msga Android only.
|
|
79
|
+
installUpdate?(url: string): Promise<void>; // Download + install APK from url. msga Android only; web app owns update policy.
|
|
80
|
+
getNetworkInfo?(): Promise<{ localIp: string; localMask: string }>; // Current device IP + subnet mask (fresh, not cached)
|
|
126
81
|
|
|
127
|
-
|
|
128
|
-
* Set always-on-top behavior
|
|
129
|
-
* @param enabled - true to keep window on top
|
|
130
|
-
* @example
|
|
131
|
-
* msgapi.setAlwaysOnTop(true);
|
|
132
|
-
*/
|
|
133
|
-
setAlwaysOnTop(enabled: boolean): void;
|
|
82
|
+
// ── Window Control ──────────────────────────────────────────
|
|
134
83
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
* msgapi.close({button: 'OK', value: 'user data'});
|
|
144
|
-
*/
|
|
145
|
-
close(result?: Partial<MsgResult>): void;
|
|
84
|
+
toggleFullscreen(): void; // Toggle fullscreen mode on/off
|
|
85
|
+
setFullscreen(enabled: boolean): void; // Set fullscreen mode
|
|
86
|
+
minimize(): void; // Minimize the window
|
|
87
|
+
maximize(): void; // Toggle maximize/restore
|
|
88
|
+
setSize(width: number, height: number): void; // Set window size in pixels
|
|
89
|
+
setPosition(x: number, y: number): void; // Set window position in pixels
|
|
90
|
+
setAlwaysOnTop(enabled: boolean): void; // Keep window on top
|
|
91
|
+
close(result?: Partial<MsgResult>): void; // Close window, return result to parent
|
|
146
92
|
|
|
147
|
-
//
|
|
148
|
-
// File System Access
|
|
149
|
-
// ========================================
|
|
93
|
+
// ── File System Access ──────────────────────────────────────
|
|
150
94
|
|
|
151
|
-
/**
|
|
152
|
-
* File system operations namespace
|
|
153
|
-
*/
|
|
154
95
|
fs?: {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
* console.log(file.content);
|
|
166
|
-
* }
|
|
167
|
-
*/
|
|
168
|
-
selectFile(options?: FileDialogOptions): Promise<SelectedFile | null>;
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Select multiple files using native file picker
|
|
172
|
-
* @param options - File dialog options
|
|
173
|
-
* @returns Array of selected files with content
|
|
174
|
-
* @example
|
|
175
|
-
* const files = await msgapi.fs.selectFiles({
|
|
176
|
-
* title: 'Open Files'
|
|
177
|
-
* });
|
|
178
|
-
*/
|
|
179
|
-
selectFiles(options?: FileDialogOptions): Promise<SelectedFile[]>;
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Save file using native save dialog
|
|
183
|
-
* @param content - File content to save
|
|
184
|
-
* @param defaultFilename - Default filename
|
|
185
|
-
* @param options - Additional dialog options
|
|
186
|
-
* @returns Path where file was saved, or null if cancelled
|
|
187
|
-
* @example
|
|
188
|
-
* const path = await msgapi.fs.saveFileAs('Hello World', 'greeting.txt');
|
|
189
|
-
*/
|
|
190
|
-
saveFileAs(
|
|
191
|
-
content: string,
|
|
192
|
-
defaultFilename?: string,
|
|
193
|
-
options?: FileDialogOptions
|
|
194
|
-
): Promise<string | null>;
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Select a directory using native folder picker
|
|
198
|
-
* @param options - Dialog options
|
|
199
|
-
* @returns Selected directory path, or null if cancelled
|
|
200
|
-
* @example
|
|
201
|
-
* const dir = await msgapi.fs.selectFolder({
|
|
202
|
-
* title: 'Select Output Folder'
|
|
203
|
-
* });
|
|
204
|
-
*/
|
|
205
|
-
selectFolder(options?: FileDialogOptions): Promise<string | null>;
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Read file contents
|
|
209
|
-
* Note: Requires allowFs: true flag when launching msgview/msger
|
|
210
|
-
* @param path - File path to read
|
|
211
|
-
* @returns File content as string
|
|
212
|
-
* @example
|
|
213
|
-
* const content = await msgapi.fs.read('/path/to/file.txt');
|
|
214
|
-
*/
|
|
215
|
-
read(path: string): Promise<string>;
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Write file contents
|
|
219
|
-
* Note: Requires allowFs: true flag when launching msgview/msger
|
|
220
|
-
* @param path - File path to write
|
|
221
|
-
* @param content - Content to write
|
|
222
|
-
* @example
|
|
223
|
-
* await msgapi.fs.write('/path/to/file.txt', 'Hello World');
|
|
224
|
-
*/
|
|
225
|
-
write(path: string, content: string): Promise<void>;
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* List directory contents
|
|
229
|
-
* Note: Requires allowFs: true flag when launching msgview/msger
|
|
230
|
-
* @param path - Directory path to list
|
|
231
|
-
* @returns Array of file information
|
|
232
|
-
* @example
|
|
233
|
-
* const files = await msgapi.fs.list('/path/to/dir');
|
|
234
|
-
*/
|
|
235
|
-
list(path: string): Promise<FileInfo[]>;
|
|
96
|
+
selectFile(options?: FileDialogOptions): Promise<SelectedFile | null>; // Native file picker (null if cancelled)
|
|
97
|
+
selectFiles(options?: FileDialogOptions): Promise<SelectedFile[]>; // Multi-file picker
|
|
98
|
+
saveFileAs(content: string, defaultFilename?: string, options?: FileDialogOptions): Promise<string | null>; // Save dialog (returns path or null)
|
|
99
|
+
selectFolder(options?: FileDialogOptions): Promise<string | null>; // Folder picker (null if cancelled)
|
|
100
|
+
read(path: string): Promise<string>; // Read file contents (requires allowFs)
|
|
101
|
+
write(path: string, content: string): Promise<void>; // Write file contents (requires allowFs)
|
|
102
|
+
list(path: string): Promise<FileInfo[]>; // List directory contents (requires allowFs)
|
|
103
|
+
exists(path: string): Promise<boolean>; // Check if file/directory exists (requires allowFs)
|
|
104
|
+
delete(path: string): Promise<void>; // Delete file or directory (requires allowFs)
|
|
105
|
+
};
|
|
236
106
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
* Note: Requires allowFs: true flag when launching msgview/msger
|
|
240
|
-
* @param path - Path to check
|
|
241
|
-
* @returns true if exists
|
|
242
|
-
* @example
|
|
243
|
-
* const exists = await msgapi.fs.exists('/path/to/file.txt');
|
|
244
|
-
*/
|
|
245
|
-
exists(path: string): Promise<boolean>;
|
|
107
|
+
// ── UDP Networking ──────────────────────────────────────────
|
|
108
|
+
// Native UDP — no httpudp WebSocket proxy needed
|
|
246
109
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
* await msgapi.fs.delete('/path/to/file.txt');
|
|
253
|
-
*/
|
|
254
|
-
delete(path: string): Promise<void>;
|
|
110
|
+
udp?: {
|
|
111
|
+
send(host: string, port: number, data: string | Uint8Array): Promise<void>; // Send UDP packet to host:port
|
|
112
|
+
listen(port: number, callback: (data: Uint8Array, sender: { host: string; port: number }) => void): Promise<{ close: () => void }>; // Listen on port (0=ephemeral), returns {close()}
|
|
113
|
+
sendReceive(host: string, port: number, data: string | Uint8Array, timeoutMs?: number): Promise<Uint8Array>; // Send and wait for response
|
|
114
|
+
broadcast(port: number, data: string | Uint8Array): Promise<void>; // Broadcast to local network
|
|
255
115
|
};
|
|
256
116
|
}
|
|
257
117
|
|
|
258
|
-
|
|
259
|
-
* Declare global window.msgapi
|
|
260
|
-
*/
|
|
118
|
+
// Global window.msgapi declaration
|
|
261
119
|
declare global {
|
|
262
120
|
interface Window {
|
|
263
121
|
msgapi?: MsgAPI;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/msgapidefs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
4
4
|
"description": "TypeScript definitions for msgapi JavaScript API (msgview/msger)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./msgapidefs.js",
|
|
@@ -34,5 +34,8 @@
|
|
|
34
34
|
"preversion": "git add -A",
|
|
35
35
|
"postversion": "git push && git push --tags",
|
|
36
36
|
"release": "npm run prerelease:local && npm version patch && npm publish --quiet"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^25.2.1"
|
|
37
40
|
}
|
|
38
41
|
}
|