@hyzyn/dsh-tty 0.18.2 → 0.19.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.
@@ -0,0 +1,365 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * dsh-tty 预览夹具:在纯静态页面里伪造 DSH 宿主环境(module loader / fetch /
4
+ * WebSocket),让 packages/tty/client.js 能在浏览器中独立渲染,用于视觉走查
5
+ * 与截图回归。仅供 scripts/preview.mjs 使用,不随 npm 包发布。
6
+ */
7
+ (function () {
8
+ 'use strict'
9
+
10
+ const React = window.React
11
+
12
+ /* ---------- react/jsx-runtime 垫片(React 18 UMD 不含该入口) ---------- */
13
+ const jsx = (type, props, key) => {
14
+ const p = {}
15
+ let children
16
+ if (props !== null && props !== undefined) {
17
+ for (const k in props) {
18
+ if (k === 'children') children = props[k]
19
+ else p[k] = props[k]
20
+ }
21
+ }
22
+ if (key !== undefined) p.key = key
23
+ if (children === undefined) return React.createElement(type, p)
24
+ return Array.isArray(children) ? React.createElement(type, p, ...children) : React.createElement(type, p, children)
25
+ }
26
+ window.__jsxRuntime = { jsx, jsxs: jsx, Fragment: React.Fragment }
27
+
28
+ /* ---------- 假配置(结构与宿主 /api/dsh-tty/config 一致) ---------- */
29
+ const CONFIG = {
30
+ enabled: true,
31
+ announceToAgent: true,
32
+ maxSessions: 4,
33
+ shell: '/bin/zsh',
34
+ term: 'xterm-256color',
35
+ colorTerm: 'truecolor',
36
+ cwd: window.__PREVIEW_CWD || '/home/user/project',
37
+ reconnectGraceSec: 120,
38
+ shellIntegration: true,
39
+ sftpStyle: window.__PREVIEW_SFTP_STYLE || 'dialog',
40
+ persistence: window.__PREVIEW_PERSISTENCE || 'off',
41
+ endOnPageClose: false,
42
+ statsEnabled: true,
43
+ sftpLimits: { maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 },
44
+ toolsRegistered: true,
45
+ sshHosts: [
46
+ { name: 'prod-web-01', host: '198.51.100.20', port: 22, username: 'deploy', auth: 'key', keyPath: '~/.ssh/id_ed25519', passphrase: '', password: '', agentForward: true, persist: false },
47
+ { name: 'staging-db', host: 'db.staging.internal', port: 2222, username: 'root', auth: 'password', keyPath: '', passphrase: '', password: 'env:STAGING_DB_PASSWORD', agentForward: false, persist: false },
48
+ ],
49
+ hostKeys: [
50
+ { host: '198.51.100.20', port: 22, fingerprint: 'SHA256:9xQ2mVr7Kp3sTf1cZbLd8uYwAe4nHjR6gPoXkMvQ2Bc' },
51
+ { host: 'db.staging.internal', port: 2222, fingerprint: 'SHA256:3fLp8Rt2Wq9yUvC4dXzN6mBsAe1hKjG7oPwXiMnQ5Bd' },
52
+ ],
53
+ tunnels: [
54
+ { name: 'staging-pg', bookName: 'staging-db', direction: 'local', localPort: 15432, remoteHost: '127.0.0.1', remotePort: 5432, localTargetHost: '127.0.0.1', localTargetPort: 5432, enabled: true },
55
+ { name: 'prod-redis', bookName: 'prod-web-01', direction: 'local', localPort: 16379, remoteHost: 'redis.internal', remotePort: 6379, localTargetHost: '127.0.0.1', localTargetPort: 6379, enabled: true },
56
+ ],
57
+ }
58
+ window.__PREVIEW_CONFIG = CONFIG
59
+
60
+ const json = (body) => Promise.resolve({ ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) })
61
+
62
+ // 字段名与宿主 /api/dsh-tty/sftp/list 的 entry 一致(isDir / isSymlink / size / mtime)
63
+ const SFTP_FILES = [
64
+ { name: 'app', isDir: true, size: 4096, mtime: 1757300000000 },
65
+ { name: 'logs', isDir: true, size: 4096, mtime: 1757312000000 },
66
+ { name: 'releases', isDir: true, size: 4096, mtime: 1757290000000 },
67
+ { name: '.env', isDir: false, size: 812, mtime: 1757311000000 },
68
+ { name: 'docker-compose.yml', isDir: false, size: 2481, mtime: 1757309000000 },
69
+ { name: 'nginx.conf', isDir: false, size: 1743, mtime: 1757301000000 },
70
+ { name: 'deploy.sh', isDir: false, size: 5120, mtime: 1757311500000 },
71
+ { name: 'current', isDir: false, isSymlink: true, size: 17, mtime: 1757311800000 },
72
+ ]
73
+ const LOCAL_FILES = [
74
+ { name: 'docs', isDir: true, size: 4096, mtime: 1757300000000 },
75
+ { name: 'packages', isDir: true, size: 4096, mtime: 1757312000000 },
76
+ { name: 'scripts', isDir: true, size: 4096, mtime: 1757290000000 },
77
+ { name: 'README.md', isDir: false, size: 23194, mtime: 1757311000000 },
78
+ { name: 'package.json', isDir: false, size: 1450, mtime: 1757309000000 },
79
+ { name: 'pnpm-lock.yaml', isDir: false, size: 53560, mtime: 1757301000000 },
80
+ ]
81
+
82
+ const route = (url, body) => {
83
+ if (url.indexOf('/api/dsh-tty/config') === 0) return { ok: true, config: CONFIG }
84
+ if (url.indexOf('/api/dsh-tty/shells') === 0) return { ok: true, shells: ['/bin/zsh', '/bin/bash', '/bin/sh', '/opt/homebrew/bin/fish'] }
85
+ if (url.indexOf('/api/dsh-tty/env-vars') === 0) return { ok: true, names: ['STAGING_DB_PASSWORD', 'PROD_DEPLOY_KEY', 'GITHUB_TOKEN', 'OPENAI_API_KEY', 'SSH_PASSPHRASE'] }
86
+ if (url.indexOf('/api/dsh-tty/tunnels') === 0) return { ok: true, tunnels: [
87
+ { name: 'staging-pg', state: 'active', connections: 2, totalConnections: 17, error: '', lastForwardError: '' },
88
+ { name: 'prod-redis', state: 'connecting', connections: 0, totalConnections: 3, error: '', lastForwardError: '' },
89
+ ] }
90
+ if (url.indexOf('/api/dsh-tty/sftp/list') === 0) return { ok: true, path: body && body.path ? body.path : '/srv/app', entries: SFTP_FILES }
91
+ if (url.indexOf('/api/dsh-tty/sftp/') === 0) return { ok: true }
92
+ if (url.indexOf('/api/dsh-tty/local-fs/list') === 0) return { ok: true, path: body && body.path ? body.path : (window.__PREVIEW_CWD || '/home/user/project'), entries: LOCAL_FILES }
93
+ if (url.indexOf('/api/dsh-tty/local-fs') === 0) return { ok: true, home: window.__PREVIEW_HOME || '/home/user' }
94
+ // 形状对齐 probeSummary():{ tcp:{ok,ms}, banner:{ok}, hostkey:{state}, auth:{ok,ms} }
95
+ if (url.indexOf('/api/dsh-tty/probe') === 0) return { ok: true, result: {
96
+ tcp: { ok: true, ms: 8 },
97
+ banner: { ok: true },
98
+ hostkey: { state: 'matched' },
99
+ auth: { ok: true, ms: 296 },
100
+ } }
101
+ if (url.indexOf('/api/dsh-tty/ssh-config') === 0) return { ok: true, hosts: [] }
102
+ if (url.indexOf('/api/dsh-tty/known-hosts') === 0) return { ok: true, keys: [] }
103
+ if (url.indexOf('/api/dsh-docker/') === 0) return dockerRoute(url.slice('/api/dsh-docker'.length), body)
104
+ return { ok: true }
105
+ }
106
+
107
+ /* ---------- 假 dsh-docker API:让 docker 面板与 tty 的 ttyTerminal 端到端联调 ---------- */
108
+ const DOCKER_TARGETS = [
109
+ { name: '目标1', kind: 'ssh', label: 'root@192.0.2.10' },
110
+ { name: '目标2', kind: 'local', label: '本机' },
111
+ // 与 tty 连接簿的 prod-web-01 对齐:连接栏「容器」按钮的命中路径(docker-dock 场景)
112
+ { name: 'prod-web-01', kind: 'ssh', label: 'deploy@198.51.100.20' },
113
+ ]
114
+ // 形状照搬宿主 /config:设置卡片会读 ttyBooks / hostKeys / ttyAvailable,
115
+ // 少一个字段就是「渲染期 undefined.length」——离线场景直接崩
116
+ window.__PREVIEW_DOCKER_CONFIG = {
117
+ enabled: true,
118
+ announceToAgent: true,
119
+ dockerBin: 'docker',
120
+ allowMutations: false,
121
+ allowExec: true,
122
+ pollIntervalSec: 5,
123
+ logTailDefault: 200,
124
+ maxOutputKb: 512,
125
+ execTimeoutSec: 30,
126
+ hostKeys: [],
127
+ ttyBooks: ['prod-web-01'],
128
+ ttyAvailable: true,
129
+ toolsRegistered: [],
130
+ targets: [
131
+ { name: '目标1', kind: 'ssh', book: '', host: '192.0.2.10', port: 22, username: 'root', auth: 'agent', agentForward: false },
132
+ { name: '目标2', kind: 'local', book: '', host: '', port: 22, username: '', auth: 'agent', agentForward: false },
133
+ { name: 'prod-web-01', kind: 'ssh', book: 'prod-web-01', host: '', port: 22, username: '', auth: 'agent', agentForward: false },
134
+ ],
135
+ }
136
+ window.__PREVIEW_DOCKER_CONTAINERS = [
137
+ { id: 'a1b2c3d4e5f6a7b8c9d0', shortId: 'a1b2c3d4e5f6', name: 'app-web-1', image: 'app:2026.09.07', state: 'running', health: 'healthy', status: 'Up 3 days', ports: [{ hostPort: 8080, containerPort: 8080, protocol: 'tcp' }], createdAt: null, runningFor: 'Up 3 days', composeProject: 'app', composeService: 'web' },
138
+ { id: 'b2c3d4e5f6a7b8c9d0e1', shortId: 'b2c3d4e5f6a7', name: 'app-worker-1', image: 'app:2026.09.07', state: 'running', health: null, status: 'Up 3 days', ports: [], createdAt: null, runningFor: 'Up 3 days', composeProject: 'app', composeService: 'worker' },
139
+ { id: 'c3d4e5f6a7b8c9d0e1f2', shortId: 'c3d4e5f6a7b8', name: 'postgres', image: 'postgres:16-alpine', state: 'running', health: 'healthy', status: 'Up 5 days', ports: [{ hostPort: 5432, containerPort: 5432, protocol: 'tcp' }], createdAt: null, runningFor: 'Up 5 days', composeProject: null, composeService: null },
140
+ { id: 'd4e5f6a7b8c9d0e1f2a3', shortId: 'd4e5f6a7b8c9', name: 'redis', image: 'redis:7-alpine', state: 'exited', health: null, status: 'Exited (0) 2 hours ago', ports: [], createdAt: null, runningFor: '', composeProject: null, composeService: null },
141
+ ]
142
+ const dockerRoute = (path, body) => {
143
+ if (path === '/config') return { ok: true, config: window.__PREVIEW_DOCKER_CONFIG }
144
+ if (path === '/targets') return { ok: true, targets: DOCKER_TARGETS }
145
+ if (path === '/containers') return { ok: true, containers: window.__PREVIEW_DOCKER_CONTAINERS }
146
+ if (path === '/images') return { ok: true, images: [
147
+ { id: 'sha256:11aa', shortId: '11aa22bb33cc', tags: ['app:2026.09.07'], size: 184320000, createdAt: null, createdSince: '2 days ago' },
148
+ { id: 'sha256:22bb', shortId: '22bb33cc44dd', tags: ['postgres:16-alpine'], size: 42000000, createdAt: null, createdSince: '3 weeks ago' },
149
+ ] }
150
+ // 详情形状按宿主 /inspect 的真实返回补齐:缺字段会让「概览」页在渲染期崩掉
151
+ // (detail.mounts.length 之类),预览也就看不到日志/概览页
152
+ if (path === '/inspect') {
153
+ const base = window.__PREVIEW_DOCKER_CONTAINERS[0]
154
+ return { ok: true, details: [{
155
+ ...base,
156
+ startedAt: '2026-09-06 04:11:22 +0800 CST',
157
+ finishedAt: null,
158
+ exitCode: null,
159
+ restartCount: 0,
160
+ restartPolicy: 'unless-stopped',
161
+ pid: 48213,
162
+ healthLogTail: null,
163
+ mounts: [{ source: '/srv/app', destination: '/app', readWrite: true }],
164
+ networks: [{ name: 'app_default', ip: '172.20.0.7' }],
165
+ entrypoint: 'docker-entrypoint.sh',
166
+ command: 'node server.js',
167
+ workingDir: '/app',
168
+ user: 'node',
169
+ }] }
170
+ }
171
+ // /logs 的真实响应形状是 { id, text, truncated }(见 dsh-docker 宿主 /logs 路由);
172
+ // 早先这里回的是裸字符串,客户端 logs.text 拿到 undefined 直接崩掉整面板
173
+ if (path === '/logs') return { ok: true, logs: {
174
+ id: body && body.id ? body.id : 'app-web-1',
175
+ truncated: false,
176
+ text: '2026-09-09T12:00:00Z INFO server listening on :8080\n2026-09-09T12:00:01Z INFO connected to postgres\n2026-09-09T12:00:02Z WARN slow query 412ms\n2026-09-09T12:00:03Z INFO request GET /health 200 3ms\n',
177
+ } }
178
+ if (path === '/stats') return { ok: true, stats: [null] }
179
+ return { ok: true }
180
+ }
181
+
182
+ const realFetch = window.fetch ? window.fetch.bind(window) : null
183
+ window.fetch = (url, init) => {
184
+ const target = typeof url === 'string' ? url : String(url && url.url ? url.url : url)
185
+ if (target.indexOf('/api/dsh-tty/') === -1 && target.indexOf('/api/dsh-docker/') === -1) {
186
+ return realFetch ? realFetch(url, init) : json({ ok: true })
187
+ }
188
+ let body = null
189
+ try {
190
+ body = init && typeof init.body === 'string' ? JSON.parse(init.body) : null
191
+ } catch {
192
+ body = null
193
+ }
194
+ return json(route(target, body))
195
+ }
196
+
197
+ /* ---------- 假 WebSocket:本地 spawn/ssh 回 ready,并投喂演示输出 ---------- */
198
+ const SESSIONS = []
199
+ class MockSocket extends EventTarget {
200
+ static CONNECTING = 0
201
+ static OPEN = 1
202
+ static CLOSING = 2
203
+ static CLOSED = 3
204
+ constructor(url) {
205
+ super()
206
+ this.url = url
207
+ this.readyState = MockSocket.CONNECTING
208
+ this.onopen = null
209
+ this.onmessage = null
210
+ this.onclose = null
211
+ this.onerror = null
212
+ window.__mockSockets.push(this)
213
+ setTimeout(() => {
214
+ if (this.readyState !== MockSocket.CONNECTING) return
215
+ this.readyState = MockSocket.OPEN
216
+ if (typeof this.onopen === 'function') this.onopen({})
217
+ }, 0)
218
+ }
219
+ addEventListener(type, fn, opts) {
220
+ super.addEventListener(type, fn, opts)
221
+ }
222
+ removeEventListener(type, fn, opts) {
223
+ super.removeEventListener(type, fn, opts)
224
+ }
225
+ _deliver(msg) {
226
+ window.__mockLog.push('out:' + msg.t + (msg.d ? '(' + msg.d.length + ')' : ''))
227
+ const event = new MessageEvent('message', { data: JSON.stringify(msg) })
228
+ if (typeof this.onmessage === 'function') this.onmessage(event)
229
+ this.dispatchEvent(event)
230
+ }
231
+ send(raw) {
232
+ let msg
233
+ try {
234
+ msg = JSON.parse(raw)
235
+ } catch {
236
+ return
237
+ }
238
+ window.__mockLog.push('in:' + msg.t)
239
+ const sid = msg.sid
240
+ if (msg.t === 'spawn' || msg.t === 'ssh') {
241
+ if (!SESSIONS.some((s) => s.sid === sid)) SESSIONS.push({ sid, attachable: true, kind: msg.t === 'ssh' ? 'ssh' : 'local', tmux: msg.persistName })
242
+ const target = msg.t === 'ssh' ? (msg.username || 'deploy') + '@' + (msg.host || '198.51.100.20') + (msg.port && Number(msg.port) !== 22 ? ':' + msg.port : '') : undefined
243
+ setTimeout(() => {
244
+ this._deliver(msg.t === 'ssh'
245
+ ? { t: 'ready', sid, kind: 'ssh', pid: null, target: msg.name ? target : target, persist: msg.persist === true }
246
+ : { t: 'ready', sid, kind: 'local', pid: 48213, persist: msg.persist === true })
247
+ setTimeout(() => this._deliver({ t: 'data', sid, d: msg.t === 'ssh' ? window.__PREVIEW_SSH_BANNER : window.__PREVIEW_BANNER }), 10)
248
+ }, 20)
249
+ return
250
+ }
251
+ if (msg.t === 'attach') {
252
+ setTimeout(() => {
253
+ this._deliver({ t: 'ready', sid, kind: 'local', pid: 48213, reattached: true })
254
+ setTimeout(() => this._deliver({ t: 'data', sid, d: window.__PREVIEW_BANNER }), 10)
255
+ }, 20)
256
+ return
257
+ }
258
+ if (msg.t === 'sessions') {
259
+ const list = window.__PREVIEW_AT_LIMIT
260
+ ? [{ sid: 'other-1', attachable: true }, { sid: 'other-2', attachable: true }, { sid: 'other-3', attachable: true }, { sid: 'other-4', attachable: true }]
261
+ : SESSIONS.slice()
262
+ setTimeout(() => this._deliver({ t: 'sessions', list }), 5)
263
+ return
264
+ }
265
+ if (msg.t === 'input') {
266
+ // 演示用:回车回一个新提示符,普通字符回显
267
+ if (typeof msg.d === 'string') {
268
+ if (msg.d === '\r' || msg.d === '\n') setTimeout(() => this._deliver({ t: 'data', sid, d: '\r\n\x1b[38;2;122;162;247m❯\x1b[0m ' }), 15)
269
+ else if (!msg.d.includes('\u0003')) setTimeout(() => this._deliver({ t: 'data', sid, d: msg.d }), 5)
270
+ }
271
+ return
272
+ }
273
+ if (msg.t === 'statsOn') {
274
+ // 与真实宿主同语义:宿主的订阅路由要求本连接上已有该 sid(会话已 ready)。
275
+ // 客户端若在 spawn 之前/之后就绪没重发,这里就什么都不回——状态条不出来。
276
+ if (!SESSIONS.some((entry) => entry.sid === sid)) {
277
+ window.__mockLog.push('in:statsOn(unknown)')
278
+ return
279
+ }
280
+ // 服务器状态条(0.17.0):立刻回一帧再每秒推一帧,数值轻微浮动(截图里能看出"在动")
281
+ this._statsSid = sid
282
+ this._statsTick = 0
283
+ if (this._statsTimer) clearInterval(this._statsTimer)
284
+ const push = () => {
285
+ this._statsTick += 1
286
+ const tick = this._statsTick
287
+ // 坏帧注入(stats-broken 场景):字符串 / 数组 / null / 越界值轮流来
288
+ const junk = window.__PREVIEW_STATS_PAYLOAD
289
+ if (Array.isArray(junk) && junk.length > 0) {
290
+ this._deliver({ t: 'stats', sid, stats: junk[(tick - 1) % junk.length] })
291
+ return
292
+ }
293
+ this._deliver({ t: 'stats', sid, stats: {
294
+ cpuPct: 18 + (tick % 5) * 9,
295
+ cores: 16,
296
+ memUsed: Math.round(53.3e9 + (tick % 3) * 0.9e9),
297
+ memTotal: 64e9,
298
+ memPct: Math.round((83.2 + (tick % 3) * 1.4) * 10) / 10,
299
+ diskUsed: 40.8e9,
300
+ diskTotal: 68.8e9,
301
+ diskPct: 59.3,
302
+ uptimeSec: 2 * 604800 + 4 * 86400 + 7 * 3600 + 16 * 60 + tick,
303
+ tcpConns: 570 + (tick % 7),
304
+ rxRate: 92.3 * 1024,
305
+ txRate: 93.1 * 1024,
306
+ // tempC 缺席:走「CPU温度 无」的降级渲染(与 macOS 本机一致)
307
+ } })
308
+ }
309
+ push()
310
+ this._statsTimer = setInterval(push, 1000)
311
+ return
312
+ }
313
+ if (msg.t === 'statsOff') {
314
+ if (this._statsTimer) {
315
+ clearInterval(this._statsTimer)
316
+ this._statsTimer = null
317
+ }
318
+ return
319
+ }
320
+ if (msg.t === 'kill') {
321
+ const idx = SESSIONS.findIndex((s) => s.sid === sid)
322
+ if (idx >= 0) SESSIONS.splice(idx, 1)
323
+ }
324
+ }
325
+ close() {
326
+ if (this._statsTimer) {
327
+ clearInterval(this._statsTimer)
328
+ this._statsTimer = null
329
+ }
330
+ this.readyState = MockSocket.CLOSED
331
+ if (typeof this.onclose === 'function') this.onclose({ code: 1000, wasClean: true })
332
+ }
333
+ }
334
+ window.__mockSockets = []
335
+ window.__mockLog = []
336
+ window.WebSocket = MockSocket
337
+
338
+ /* ---------- module loader ---------- */
339
+ window.__modules = new Map()
340
+ window.__ModuleLoader__ = {
341
+ load(def) {
342
+ window.__modules.set(def.id, def)
343
+ // 兼容:夹具最早只跑 tty 一个插件
344
+ if (def.id === '@hyzyn/dsh-tty') window.__ttyModule = def
345
+ },
346
+ }
347
+
348
+ window.__PREVIEW_SSH_BANNER =
349
+ '\x1b[38;2;110;118;129mWelcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-45-generic aarch64)\x1b[0m\r\n' +
350
+ '\x1b[38;2;110;118;129mLast login: Mon Sep 8 12:38:52 2026 from 198.51.100.7\x1b[0m\r\n' +
351
+ '\x1b[38;2;126;206;153mdeploy@prod-web-01\x1b[0m:\x1b[38;2;122;162;247m~/app\x1b[0m$ docker compose ps\r\n' +
352
+ '\x1b[38;2;110;118;129mNAME IMAGE STATUS PORTS\x1b[0m\r\n' +
353
+ 'app-web-1 app:2026.09.07 \x1b[38;2;126;206;153mUp 3 days\x1b[0m 0.0.0.0:8080->8080/tcp\r\n' +
354
+ 'app-worker-1 app:2026.09.07 \x1b[38;2;126;206;153mUp 3 days\x1b[0m\r\n' +
355
+ '\x1b[38;2;126;206;153mdeploy@prod-web-01\x1b[0m:\x1b[38;2;122;162;247m~/app\x1b[0m$ '
356
+
357
+ window.__PREVIEW_BANNER =
358
+ '\x1b[38;2;110;118;129mLast login: Mon Sep 8 12:41:07 on ttys003\x1b[0m\r\n' +
359
+ '\x1b[38;2;122;162;247m~/projects/demo-app\x1b[0m \x1b[38;2;158;206;106mpnpm\x1b[0m -r build\r\n' +
360
+ '\x1b[38;2;97;175;239mpackages/tty build$\x1b[0m tsc -p tsconfig.json && node scripts/build-client.mjs\r\n' +
361
+ ' client-src/index.js \x1b[38;2;229;192;123m486.2kb\x1b[0m \x1b[38;2;110;118;129m⟳\x1b[0m 200ms\r\n' +
362
+ '\x1b[38;2;152;195;121m[dsh-tty] client.js built\x1b[0m\r\n' +
363
+ '\x1b[38;2;110;118;129mDone in 1.8s\x1b[0m\r\n' +
364
+ '\x1b[38;2;122;162;247m~/projects/demo-app\x1b[0m \x1b[38;2;122;162;247m❯\x1b[0m '
365
+ })()