@myagentroam/node 0.13.12 → 0.13.14
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/dist/connector.js +17 -3
- package/dist/marctl/package.json +1 -1
- package/dist/remote-desktop/macos-remote-desktop-runtime.js +127 -33
- package/dist/remote-desktop/macvnc-managed-process.js +7 -1
- package/dist/remote-desktop/windows-remote-desktop-runtime.js +33 -8
- package/dist/service/node-request-service.js +1 -1
- package/package.json +8 -6
package/dist/connector.js
CHANGED
|
@@ -13,7 +13,7 @@ import { NodeModelUsageService } from './service/model-usage-service.js';
|
|
|
13
13
|
import { dirname, resolve } from 'node:path';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import WebSocket from 'ws';
|
|
16
|
-
import { createEnvelope, directNodeCommandSchema, remoteDesktopDescriptionSchema } from '@myagentroam/protocol';
|
|
16
|
+
import { createEnvelope, directNodeCommandSchema, remoteDesktopDescriptionSchema, remoteDesktopDescribeInputSchema } from '@myagentroam/protocol';
|
|
17
17
|
import { loadNodeConfig, computerUseRuntimeExecutable, directTransferUdpPortRange, nodeDataDirectory, nodeDatabasePath } from './config.js';
|
|
18
18
|
import { NodeMetrics, nodeLog } from './operational.js';
|
|
19
19
|
import { assertLocalSqlitePath } from './storage.js';
|
|
@@ -1550,16 +1550,30 @@ export class NodeConnector {
|
|
|
1550
1550
|
service: this.agentNetworkService,
|
|
1551
1551
|
observations: this.agentNetworkObservations
|
|
1552
1552
|
}));
|
|
1553
|
-
this.operationRouter.register('desktop.describe', async () => {
|
|
1553
|
+
this.operationRouter.register('desktop.describe', async (data) => {
|
|
1554
|
+
if (!isPlainRecord(data))
|
|
1555
|
+
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
1556
|
+
const { __workspaceWatchSessionHash: _sessionHash, __requestUserId: _userId, __usageGrant: _usageGrant, __marctlCaller: _caller, ...input } = data;
|
|
1557
|
+
const parsed = remoteDesktopDescribeInputSchema.safeParse(input);
|
|
1558
|
+
if (!parsed.success)
|
|
1559
|
+
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
1560
|
+
const selection = parsed.data;
|
|
1554
1561
|
const runtime = this.desktopRuntime;
|
|
1555
1562
|
if (!runtime)
|
|
1556
1563
|
throw new RemoteDesktopError('DESKTOP_UNAVAILABLE');
|
|
1557
1564
|
if (runtime instanceof WindowsRemoteDesktopRuntime ||
|
|
1558
1565
|
runtime instanceof MacosRemoteDesktopRuntime)
|
|
1559
1566
|
await runtime.refresh(AbortSignal.timeout(10_000));
|
|
1560
|
-
const description = remoteDesktopDescriptionSchema.safeParse(runtime
|
|
1567
|
+
const description = remoteDesktopDescriptionSchema.safeParse(runtime instanceof MacosRemoteDesktopRuntime
|
|
1568
|
+
? runtime.describe(selection)
|
|
1569
|
+
: runtime.describe());
|
|
1561
1570
|
if (!description.success)
|
|
1562
1571
|
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
1572
|
+
if ((selection.desktopGeneration !== undefined &&
|
|
1573
|
+
selection.desktopGeneration !== description.data.desktopGeneration) ||
|
|
1574
|
+
(selection.displayId !== undefined &&
|
|
1575
|
+
!description.data.displays.some((display) => display.displayId === selection.displayId)))
|
|
1576
|
+
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
1563
1577
|
return description.data;
|
|
1564
1578
|
});
|
|
1565
1579
|
this.operationRouter.registerAll(this.promptCompletion.operations());
|
package/dist/marctl/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"type":"module","version":"0.13.
|
|
1
|
+
{"type":"module","version":"0.13.14"}
|
|
@@ -55,67 +55,161 @@ async function queryScreens(binary, signal) {
|
|
|
55
55
|
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
56
56
|
return parsed.displays;
|
|
57
57
|
}
|
|
58
|
-
/** Owns
|
|
58
|
+
/** Owns per-display capture identities and backend pools; selecting a display never changes another page. */
|
|
59
59
|
export class MacosRemoteDesktopRuntime {
|
|
60
60
|
options;
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
61
|
+
#pools = new Set();
|
|
62
|
+
#entries = new Map();
|
|
63
|
+
#screens = [];
|
|
64
64
|
#signature = '';
|
|
65
65
|
#revision = 0;
|
|
66
66
|
#disposed = false;
|
|
67
|
+
#topologyAvailable = false;
|
|
68
|
+
#refreshTail = Promise.resolve();
|
|
67
69
|
constructor(options) {
|
|
68
70
|
this.options = options;
|
|
69
|
-
|
|
71
|
+
}
|
|
72
|
+
#createPool(displayId) {
|
|
73
|
+
const pool = new TigerVncBackendPool(async (signal, onUnexpectedExit) => {
|
|
74
|
+
const signature = this.#signature;
|
|
75
|
+
const screens = await this.#probeTopology(signal);
|
|
76
|
+
if (JSON.stringify(screens) !== this.#signature)
|
|
77
|
+
this.#invalidateTopology();
|
|
78
|
+
const displayIndex = screens.findIndex((screen) => screen.id === displayId);
|
|
79
|
+
if (this.#disposed ||
|
|
80
|
+
!this.#topologyAvailable ||
|
|
81
|
+
displayIndex < 0 ||
|
|
82
|
+
this.#signature !== signature)
|
|
83
|
+
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
84
|
+
const backend = await MacVncManagedProcess.start({ binaryPath: this.options.binaryPath, displayIndex, onUnexpectedExit }, signal);
|
|
85
|
+
// The pool owns cleanup before the post-launch check can fail. A failed stop
|
|
86
|
+
// must retain the backend/reference so its attachment can retry cleanup.
|
|
87
|
+
return {
|
|
88
|
+
stop: () => backend.stop(),
|
|
89
|
+
connect: async (maxPixels, connectSignal) => {
|
|
90
|
+
const current = await this.#probeTopology(connectSignal);
|
|
91
|
+
if (JSON.stringify(current) !== this.#signature)
|
|
92
|
+
this.#invalidateTopology();
|
|
93
|
+
if (this.#disposed || !this.#topologyAvailable || this.#signature !== signature)
|
|
94
|
+
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
95
|
+
return backend.connect(maxPixels, connectSignal);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}, () => this.options.fence('DESKTOP_BACKEND_CLOSED'));
|
|
99
|
+
this.#pools.add(pool);
|
|
100
|
+
return pool;
|
|
70
101
|
}
|
|
71
102
|
static async start(options, signal) {
|
|
72
103
|
const runtime = new MacosRemoteDesktopRuntime(options);
|
|
73
104
|
await runtime.refresh(signal);
|
|
74
105
|
return runtime;
|
|
75
106
|
}
|
|
76
|
-
|
|
77
|
-
const
|
|
107
|
+
refresh(signal) {
|
|
108
|
+
const result = this.#refreshTail.then(() => this.#refresh(signal));
|
|
109
|
+
this.#refreshTail = result.catch(() => { });
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
#invalidateTopology() {
|
|
113
|
+
const wasAvailable = this.#topologyAvailable;
|
|
114
|
+
this.#topologyAvailable = false;
|
|
115
|
+
if (wasAvailable)
|
|
116
|
+
this.options.fence('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
117
|
+
}
|
|
118
|
+
async #probeTopology(signal) {
|
|
119
|
+
if (this.#disposed)
|
|
120
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
|
|
121
|
+
try {
|
|
122
|
+
return await queryScreens(this.options.topologyBinary, signal);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
// Closing one page aborts its probe; it is not evidence of desktop loss.
|
|
126
|
+
if (!signal.aborted)
|
|
127
|
+
this.#invalidateTopology();
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async #refresh(signal) {
|
|
132
|
+
// Keep native enumeration order: macVNC's -display takes an index, not a display ID.
|
|
133
|
+
const screens = await this.#probeTopology(signal);
|
|
78
134
|
const signature = JSON.stringify(screens);
|
|
79
135
|
if (this.#disposed)
|
|
80
136
|
throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
|
|
81
|
-
if (signature === this.#signature)
|
|
137
|
+
if (signature === this.#signature && this.#topologyAvailable)
|
|
82
138
|
return;
|
|
83
|
-
|
|
84
|
-
|
|
139
|
+
// Revoke old captures before validating new geometry. A failed probe/layout
|
|
140
|
+
// must not leave the previous grants current or revive them after recovery.
|
|
141
|
+
this.#invalidateTopology();
|
|
142
|
+
const selectableDisplays = screens.map((screen) => ({
|
|
143
|
+
displayId: `display_${screen.id}`,
|
|
144
|
+
name: `Display ${screen.id}`,
|
|
145
|
+
primary: screen.primary
|
|
146
|
+
}));
|
|
147
|
+
const layouts = screens.map((screen) => {
|
|
148
|
+
const displayId = `display_${screen.id}`;
|
|
149
|
+
const layout = remoteDesktopDescriptionSchema.parse({
|
|
150
|
+
...macosCombinedDesktopLayout([{ ...screen, primary: true }], this.#entries.get(displayId)?.layout.desktopGeneration ?? randomUUID(), this.#revision + 1),
|
|
151
|
+
selectableDisplays
|
|
152
|
+
});
|
|
153
|
+
if (layout.framebuffer.width * layout.framebuffer.height > this.options.maxPixels)
|
|
154
|
+
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
155
|
+
return { screen, displayId, layout };
|
|
156
|
+
});
|
|
157
|
+
if (!layouts.length)
|
|
85
158
|
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
86
|
-
|
|
87
|
-
|
|
159
|
+
this.#entries = new Map(layouts.map(({ screen, displayId, layout }) => [
|
|
160
|
+
displayId,
|
|
161
|
+
{ layout, pool: this.#entries.get(displayId)?.pool ?? this.#createPool(screen.id) }
|
|
162
|
+
]));
|
|
163
|
+
this.#screens = screens;
|
|
88
164
|
this.#signature = signature;
|
|
89
165
|
this.#revision++;
|
|
90
|
-
this.#
|
|
166
|
+
this.#topologyAvailable = true;
|
|
91
167
|
}
|
|
92
|
-
describe() {
|
|
93
|
-
|
|
168
|
+
describe(selection = {}) {
|
|
169
|
+
const entry = selection.desktopGeneration !== undefined
|
|
170
|
+
? [...this.#entries.values()].find((candidate) => candidate.layout.desktopGeneration === selection.desktopGeneration)
|
|
171
|
+
: this.#entries.get(selection.displayId ?? `display_${this.#screens.find((screen) => screen.primary)?.id}`);
|
|
172
|
+
if (this.#disposed)
|
|
94
173
|
throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
|
|
95
|
-
|
|
174
|
+
if (!this.#topologyAvailable)
|
|
175
|
+
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
176
|
+
if (!entry)
|
|
177
|
+
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
178
|
+
if (!entry.pool.isAvailable())
|
|
179
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
|
|
180
|
+
return structuredClone(entry.layout);
|
|
96
181
|
}
|
|
97
182
|
isCurrent = (command) => !this.#disposed &&
|
|
98
|
-
this.#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
createAttachment = (command, frontend, callbacks) =>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
183
|
+
this.#topologyAvailable &&
|
|
184
|
+
[...this.#entries.values()].some((entry) => entry.pool.isAvailable() &&
|
|
185
|
+
entry.layout.desktopGeneration === command.desktopGeneration &&
|
|
186
|
+
entry.layout.topologyRevision === command.topologyRevision);
|
|
187
|
+
createAttachment = (command, frontend, callbacks) => {
|
|
188
|
+
const entry = [...this.#entries.values()].find((candidate) => candidate.layout.desktopGeneration === command.desktopGeneration);
|
|
189
|
+
if (!entry || !this.isCurrent(command))
|
|
190
|
+
throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
|
|
191
|
+
return new PooledDesktopAttachment(entry.pool, frontend, (backend) => new RemoteDesktopAttachment({
|
|
192
|
+
attachmentId: command.attachmentId,
|
|
193
|
+
frontend,
|
|
194
|
+
backend,
|
|
195
|
+
mode: command.mode,
|
|
196
|
+
maxPixels: this.options.maxPixels,
|
|
197
|
+
isCurrentTopology: (width, height) => this.isCurrent(command) &&
|
|
198
|
+
entry.layout.framebuffer.width === width &&
|
|
199
|
+
entry.layout.framebuffer.height === height,
|
|
200
|
+
onAttached: callbacks.attached,
|
|
201
|
+
onFailure: callbacks.failed
|
|
202
|
+
}));
|
|
203
|
+
};
|
|
114
204
|
async dispose() {
|
|
115
205
|
if (!this.#disposed) {
|
|
116
206
|
this.#disposed = true;
|
|
117
207
|
this.options.fence('DESKTOP_SESSION_UNAVAILABLE');
|
|
118
208
|
}
|
|
119
|
-
await this.#pool.dispose();
|
|
209
|
+
const results = await Promise.allSettled([...this.#pools].map((pool) => pool.dispose()));
|
|
210
|
+
for (const result of results)
|
|
211
|
+
if (result.status === 'rejected')
|
|
212
|
+
throw result.reason;
|
|
213
|
+
this.#pools.clear();
|
|
120
214
|
}
|
|
121
215
|
}
|
|
@@ -47,6 +47,11 @@ export class MacVncManagedProcess {
|
|
|
47
47
|
static async start(options, signal) {
|
|
48
48
|
if (process.platform !== 'darwin' || !isAbsolute(options.binaryPath))
|
|
49
49
|
throw new RemoteDesktopError('DESKTOP_BACKEND_CONFIG_INVALID');
|
|
50
|
+
if (options.displayIndex !== undefined &&
|
|
51
|
+
(!Number.isInteger(options.displayIndex) ||
|
|
52
|
+
options.displayIndex < 0 ||
|
|
53
|
+
options.displayIndex >= 64))
|
|
54
|
+
throw new RemoteDesktopError('DESKTOP_BACKEND_CONFIG_INVALID');
|
|
50
55
|
const deadline = AbortSignal.any([signal, AbortSignal.timeout(10_000)]);
|
|
51
56
|
if (deadline.aborted)
|
|
52
57
|
throw new RemoteDesktopError('DESKTOP_SETUP_CANCELLED');
|
|
@@ -79,7 +84,8 @@ export class MacVncManagedProcess {
|
|
|
79
84
|
'127.0.0.1',
|
|
80
85
|
'-rfbauth',
|
|
81
86
|
config,
|
|
82
|
-
'-alwaysshared'
|
|
87
|
+
'-alwaysshared',
|
|
88
|
+
...(options.displayIndex === undefined ? [] : ['-display', String(options.displayIndex)])
|
|
83
89
|
], {
|
|
84
90
|
cwd: dirname(options.binaryPath),
|
|
85
91
|
windowsHide: true,
|
|
@@ -11,13 +11,33 @@ const run = promisify(execFile);
|
|
|
11
11
|
const SCREEN_QUERY = `
|
|
12
12
|
$ErrorActionPreference = "Stop"
|
|
13
13
|
if (-not [Environment]::UserInteractive) { exit 2 }
|
|
14
|
-
Add-Type -
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
Add-Type -TypeDefinition @'
|
|
15
|
+
using System;
|
|
16
|
+
using System.Runtime.InteropServices;
|
|
17
|
+
public static class MarRemoteDesktopDpi {
|
|
18
|
+
[DllImport("user32.dll", SetLastError = true)]
|
|
19
|
+
public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
|
|
20
|
+
}
|
|
21
|
+
'@
|
|
22
|
+
# Query every monitor in physical pixels, matching UltraVNC even with mixed DPI scales.
|
|
23
|
+
$previousDpiContext = [MarRemoteDesktopDpi]::SetThreadDpiAwarenessContext([IntPtr](-4))
|
|
24
|
+
if ($previousDpiContext -eq [IntPtr]::Zero) {
|
|
25
|
+
throw [System.ComponentModel.Win32Exception]::new(
|
|
26
|
+
[System.Runtime.InteropServices.Marshal]::GetLastWin32Error(),
|
|
27
|
+
"Cannot query physical desktop coordinates."
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
32
|
+
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
|
|
33
|
+
$screens = @([System.Windows.Forms.Screen]::AllScreens | ForEach-Object {
|
|
34
|
+
@{ name=$_.DeviceName; primary=$_.Primary; x=$_.Bounds.X; y=$_.Bounds.Y;
|
|
35
|
+
width=$_.Bounds.Width; height=$_.Bounds.Height }
|
|
36
|
+
})
|
|
37
|
+
ConvertTo-Json -InputObject $screens -Compress
|
|
38
|
+
} finally {
|
|
39
|
+
[void][MarRemoteDesktopDpi]::SetThreadDpiAwarenessContext($previousDpiContext)
|
|
40
|
+
}
|
|
21
41
|
`;
|
|
22
42
|
/** Maps Windows virtual-screen coordinates into the full VNC framebuffer. */
|
|
23
43
|
export function windowsDesktopLayout(screens, generation, revision) {
|
|
@@ -79,7 +99,12 @@ export class WindowsRemoteDesktopRuntime {
|
|
|
79
99
|
async refresh(signal) {
|
|
80
100
|
const screens = (await queryScreens(signal)).sort((left, right) => left.name.localeCompare(right.name));
|
|
81
101
|
const signature = JSON.stringify(screens.map((screen) => [
|
|
82
|
-
screen.name,
|
|
102
|
+
screen.name,
|
|
103
|
+
screen.primary,
|
|
104
|
+
screen.x,
|
|
105
|
+
screen.y,
|
|
106
|
+
screen.width,
|
|
107
|
+
screen.height
|
|
83
108
|
]));
|
|
84
109
|
if (this.#disposed)
|
|
85
110
|
throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
|
|
@@ -47,7 +47,7 @@ export class NodeRequestService {
|
|
|
47
47
|
await this.options.authorizeMarctlOperation(marctlCaller, payload.operation, payload.data, authorization.userId);
|
|
48
48
|
}
|
|
49
49
|
const usageGrant = payload.userAuthorization.usageGrant;
|
|
50
|
-
const data = payload.data !== null && typeof payload.data === 'object'
|
|
50
|
+
const data = payload.data !== null && typeof payload.data === 'object' && !Array.isArray(payload.data)
|
|
51
51
|
? (() => {
|
|
52
52
|
const { __marctlCaller: _untrustedCaller, __usageGrant: _untrustedGrant, __marctlExecutionResources: _untrustedExecutionResources, __requestAbortSignal: _untrustedSignal, ...input } = payload.data;
|
|
53
53
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myagentroam/node",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.14",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/myagentroam/mar.git",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@anthropic-ai/claude-agent-sdk": "0.3.233",
|
|
39
39
|
"@modelcontextprotocol/sdk": "1.32.0",
|
|
40
|
-
"@myagentroam/binaries": "0.10.
|
|
40
|
+
"@myagentroam/binaries": "0.10.4",
|
|
41
41
|
"@opencode-ai/sdk": "1.18.20",
|
|
42
42
|
"minimatch": "^10.2.6",
|
|
43
43
|
"node-datachannel": "0.33.4",
|
|
@@ -49,14 +49,14 @@
|
|
|
49
49
|
"turndown-plugin-gfm": "^1.0.2",
|
|
50
50
|
"ws": "^8.21.3",
|
|
51
51
|
"zod": "4.4.3",
|
|
52
|
-
"@myagentroam/
|
|
53
|
-
"@myagentroam/
|
|
52
|
+
"@myagentroam/protocol": "0.13.14",
|
|
53
|
+
"@myagentroam/agent": "0.13.14"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/turndown": "^5.0.6",
|
|
57
57
|
"@types/ws": "^8.18.1",
|
|
58
58
|
"@myagentroam/code-navigation": "0.11.1",
|
|
59
|
-
"@myagentroam/marctl": "0.13.
|
|
59
|
+
"@myagentroam/marctl": "0.13.14"
|
|
60
60
|
},
|
|
61
61
|
"exports": {
|
|
62
62
|
"./config": {
|
|
@@ -75,6 +75,8 @@
|
|
|
75
75
|
"start": "node dist/main.js",
|
|
76
76
|
"test:unit": "vitest run --config vitest.config.mts && node --test test/unit/*.test.mjs",
|
|
77
77
|
"test:e2e:real": "vitest run --config vitest.e2e.config.mts",
|
|
78
|
-
"test:e2e:computer-use:windows": "node --test test/e2e/computer-use-windows.test.mjs"
|
|
78
|
+
"test:e2e:computer-use:windows": "node --test test/e2e/computer-use-windows.test.mjs",
|
|
79
|
+
"test:e2e:remote-desktop:windows": "node --test test/e2e/remote-desktop-windows.test.mjs",
|
|
80
|
+
"test:e2e:remote-desktop:macos": "node --test test/e2e/remote-desktop-macos.test.mjs"
|
|
79
81
|
}
|
|
80
82
|
}
|