openvibers/openvibe.bot Commits

A phone pairs as a device of kind phone, from a code at /link/phone (#329)

OP OpenVibers committed 6 days ago · 65226b7645 · 6 files, +169 −11

* A phone pairs as a device of kind phone, from a code at /link/phone

OpenVibe Link on the phone (plan 2026-11-07 §13.2) pairs exactly as the Node does, but for its person
rather than a robot. Network mints the code under ref `phone:<owner>`, the app redeems it there and
binds on Bot with a node token.

- bindNode: a `phone:` pairing makes a device of kind 'phone', owned by the person, with no robot ids
  and no video; the migration grows devices_kind_check to allow it.
- issuePublishKey: a device with no robot gets no stream key instead of failing (a phone serves none).
- listMachines leaves phones out: /machines is for the person's computers, not their phone.
- GET /link/phone mints the code and shows it with a QR carrying openvibe-link://pair?code=…&network=…
  (the app's own link; no code is minted for a cross-site navigation or a prefetch, as /pair/:id does).

Checked: test/phone-pairing.test.js (bind makes the phone device; it is not a machine; mint answers the
app link; the page carries the code and the QR box; a guest is refused), and the machine, device,
desk and node-principal tests still pass.

* The phone QR carries the pairing id, so a wrong code counts against that pairing

* The machines page points at Link on your phone

* The phone QR names no server

The app talks only to its build's own OpenVibe.Network (a build constant; a debug build may point elsewhere
behind a visible banner) and refuses a link that names another. The QR therefore carries the code and the
pairing id, nothing else.

* /link/phone says the Android app is in testing until its download is published

It linked 'the latest APK' to the Node's GitHub releases, which have no APK (there is no signed release yet), and
minted a pairing code for an app nobody can install. Until LINK_ANDROID_APK_URL names the published download (https
on GitHub releases or openvibe.link), the page says the app is in testing and not published, mints no code, and
/machines does not link it. With the URL set, the page links it and shows the QR and the code, whose expiry now reads
'for the next 15 minutes (until 18:42 UTC)' instead of an ISO timestamp.

Changes

ddiff --git a/.env.example b/.env.example
iindex 3b3ac7e..1fa7b77 100644
---- a/.env.example
++++ b/.env.example
@@@ -118,3 +118,8 @@ OV_OAUTH_CLIENT_SECRET=
# The anonymous read-only /watch socket behind the embed: open sockets per client address and watchers per robot.
#BOT_WATCH_MAX_PER_IP=20
#BOT_WATCH_MAX_PER_ROBOT=500
++
++# OpenVibe Link for Android: the published download (the signed release APK), https on GitHub releases or openvibe.link.
++# Unset until the owner's release key exists: /link/phone then says the app is in testing and mints no code, and
++# /machines does not link it.
++# LINK_ANDROID_APK_URL=https://github.com/OpenVibers/OpenVibe.Node/releases/download/vX.Y.Z/openvibe-link.apk
ddiff --git a/migrations/0033_device_kind_phone.sql b/migrations/0033_device_kind_phone.sql
nnew file mode 100644
iindex 0000000..1e6c669
---- /dev/null
++++ b/migrations/0033_device_kind_phone.sql
@@@ -0,0 +1,7 @@
++-- phase: expand
++-- Link on the phone (plan 2026-11-07 §13.2): a Network-paired phone binds as a device of its own kind. It serves no
++-- robot and publishes no video; it is the person's device, with their subject and no robot ids. Every kind made
++-- before keeps it.
++ALTER TABLE devices DROP CONSTRAINT IF EXISTS devices_kind_check;
++ALTER TABLE devices ADD CONSTRAINT devices_kind_check
++ CHECK (kind IN ('onboard', 'bridge', 'server', 'phone'));
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 5422055..3a25d64 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -80,6 +80,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
/** The last online state Bot reported to Network for a machine, so a change is reported once. */
const reportedState = new Map();
const workerRef = (subject) => `worker:${subject}`;
++ // The phone app (plan 2026-11-07 §13.2): its pairing names the person, and its device is kind 'phone'.
++ const phoneRef = (subject) => `phone:${subject}`;
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
@@@ -697,8 +699,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
// A worker machine (plan T14/T16) runs its owner's own jobs and serves no robot: its pairing names the owner
// (ref `worker:<owner>`), and the device keeps that owner.
const worker = !!(p.owner && p.owner.kind === 'user' && USER_SUBJECT.test(String(p.owner.subject)) && p.paired_for.ref === workerRef(p.owner.subject));
-- const robot = !worker && isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;
-- if (!worker && (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject)) refuse("the machine was not paired for one of its owner's robots");
++ // A phone pairs for its person, not for a robot: it is a device of kind 'phone' with no robot and no video.
++ const phone = !!(p.owner && p.owner.kind === 'user' && USER_SUBJECT.test(String(p.owner.subject)) && p.paired_for.ref === phoneRef(p.owner.subject));
++ const robot = !worker && !phone && isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;
++ if (!worker && !phone && (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject)) refuse("the machine was not paired for one of its owner's robots");
const at = iso(now());
const name = typeof p.name === 'string' ? storable(p.name).trim().slice(0, 80) || null : null;
// Two first connections at once: the unique index lets one insert win, and the other reads it.
@@@ -706,8 +710,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
`INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, node_principal, owner_subject, created_at, updated_at)
VALUES ($1, $2::jsonb, $3, $4, NULL, '[]'::jsonb, '{}'::jsonb, NULL, $5, $6, $7, $7)
ON CONFLICT (node_principal) WHERE node_principal IS NOT NULL AND revoked_at IS NULL DO NOTHING RETURNING *`,
-- [prefixedId('dev', now()), JSON.stringify(worker ? [] : [robot.id]), name, worker ? 'server' : 'onboard', principalId,
-- worker ? p.owner.subject : null, at]);
++ [prefixedId('dev', now()), JSON.stringify(worker || phone ? [] : [robot.id]), name, worker ? 'server' : phone ? 'phone' : 'onboard', principalId,
++ worker || phone ? p.owner.subject : null, at]);
changed();
return inserted || db.maybe('SELECT * FROM devices WHERE node_principal = $1 AND revoked_at IS NULL', [principalId]);
}
@@@ -732,10 +736,27 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function listMachines(subject) {
return db.many(
`SELECT d.* FROM devices d
-- WHERE d.revoked_at IS NULL AND (d.owner_subject = $1 OR (d.capabilities -> 'worker' IS NOT NULL AND EXISTS (
++ WHERE d.revoked_at IS NULL AND d.kind <> 'phone' AND (d.owner_subject = $1 OR (d.capabilities -> 'worker' IS NOT NULL AND EXISTS (
SELECT 1 FROM robots r WHERE r.owner_subject = $1 AND d.robot_ids @> jsonb_build_array(r.id))))
ORDER BY d.created_at DESC`, [subject]);
}
++ // ── Link on the phone (plan 2026-11-07 §13.2) ──────────────────────────────────────────────────
++ /**
++ * A one-time code for the person's phone (OpenVibe Link for Android). Network pairs it with ref `phone:<owner>`;
++ * the app redeems the code there and binds on Bot with a node token, and the device it makes is kind 'phone',
++ * with that owner and no robot. Network keeps one live code per ref, so a new one replaces an unused one.
++ * → { code, expires_at, pairing_id, qr }: `qr` is what openvibe.bot/link/phone draws, and what the app's scanner reads.
++ */
++ async function mintPhone(ownerSubject) {
++ if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');
++ const p = await nodes.pair({ subject: ownerSubject, ref: phoneRef(ownerSubject) });
++ if (!p || typeof p.pairing_id !== 'string' || typeof p.code !== 'string') fail(503, 'bot.network_unavailable', 'Network answered no pairing code');
++ return {
++ code: p.code, expires_at: p.expires_at, pairing_id: p.pairing_id,
++ // No network=: the app talks only to its own build's server, and a link naming another is refused.
++ qr: `openvibe-link://pair?code=${encodeURIComponent(p.code)}&pairing=${encodeURIComponent(p.pairing_id)}`,
++ };
++ }
// ── The live screen (plan T14, contracts 0.182.0) ──────────────────────────────────────────────────────
/**
* A new ingest key for the machine's own screen stream on OpenRestream, as its owner → { streamId, key, whipUrl }.
@@@ -934,7 +955,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function issuePublishKey(id) {
const live = await db.maybe('SELECT * FROM devices WHERE id = $1 AND revoked_at IS NULL', [id]);
if (!live) fail(404, 'bot.device_not_found', 'no live device');
-- const robot = await getRobot(json(live.robot_ids, [])[0]);
++ const robotIds = json(live.robot_ids, []);
++ // A phone (kind 'phone') serves no robot, so there is no stream key to issue for it.
++ const robot = robotIds.length ? await getRobot(robotIds[0]) : null;
const issued = await newStreamKey(robot);
const device = await db.tx(async (t) => {
if (issued) await keepStream(t, robot.id, issued.streamId, id);
@@@ -1496,6 +1519,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice, setSharedDesks, listSharedDesks,
setRoles: setMachineRoles, rename: renameMachine, runningJobs, reportStatus: reportMachineStatus,
screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
++ phone: { mint: mintPhone, ref: phoneRef },
deskLeases,
streaming: { get: streaming, set: setStreaming },
video: { live: liveVideo },
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 402f8c2..a1c09c1 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -728,6 +728,43 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
return page('Add a device', body);
}
++/**
++ * /link/phone (plan 2026-11-07 §13.2): link the Android app to this account. The code is Network's one-time pairing
++ * code; the QR carries it in the app's own link, so scanning it opens OpenVibe Link with the code filled in.
++ */
++function renderPhoneLinkPage({ pairing = null, apkUrl = null } = {}) {
++ if (!apkUrl || !pairing) {
++ const body = `${topbar('<span class="crumb">Link on your phone</span>')}
++<main class="page pairing">
++<h1>Link on your phone</h1>
++<p>OpenVibe Link for Android puts your agents' approvals in your pocket and adds <b>Send to OpenVibe</b> to every app's
++share sheet. It is in testing and not published yet: when it is, you install it from here and pair it with a code.</p>
++<p class="muted"><a href="/machines">Your machines</a> · <a href="/robots">Your robots</a></p>
++</main>`;
++ return page('Link on your phone', body, { description: 'OpenVibe Link for Android: in testing, not published yet.' });
++ }
++ const qr = pairing.qr ? qrSvg(pairing.qr, { label: 'Opens OpenVibe Link with this pairing code' }) : '';
++ const minutes = Math.max(1, Math.round((Date.parse(pairing.expires_at) - Date.now()) / 60000)) || 15;
++ const body = `${topbar('<span class="crumb">Link on your phone</span>')}
++<main class="page pairing">
++<h1>Link on your phone</h1>
++<p>OpenVibe Link puts your agents' approvals in your pocket and adds <b>Send to OpenVibe</b> to every app's share sheet.
++First <a href="${esc(apkUrl)}" rel="noopener">install it on your Android phone</a>, then scan or type the code below.</p>
++<ol class="steps">
++<li class="card phone-pair"><h2>Scan this with the phone</h2>
++<div class="qr-box">${qr}</div>
++<p class="muted">The phone's camera opens OpenVibe Link with the code filled in. No password is typed on the phone here:
++the code pairs the device, and the app asks you to sign in separately.</p></li>
++<li class="card"><h2>Or type the code in the app</h2>
++<p><strong class="code">${esc(pairing.code)}</strong></p>
++<p class="muted">It works once, for the next ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} (until <time datetime="${esc(pairing.expires_at)}">${esc(String(pairing.expires_at).slice(11, 16))} UTC</time>).</p></li>
++</ol>
++<p class="muted">The phone shows up on your account as a device of kind <b>phone</b>. It has no camera or screen access, and
++nothing on the phone is automated. <a href="/machines">Your machines</a> · <a href="/robots">Your robots</a></p>
++</main>`;
++ return page('Link on your phone', body, { description: 'Pair the OpenVibe Link Android app with a one-time code or its QR code.' });
++}
++
function renderClaimPage({ claim, models = [], values = {}, error = null, now = Date.now() }) {
const hw = claim.hardware || {};
const minutes = (ms) => Math.max(0, Math.round(ms / 60000));
@@@ -782,7 +819,7 @@ ${chatUrl ? `<li class="card"><h2>Drive it from your chat</h2><p class="muted">L
* /machines (plan T14/T16): a person's own machines that run jobs for them, and the command that adds one. A machine
* here runs its owner's jobs only (Bot refuses anyone else's), connects out, and keeps its local limits and kill switch.
*/
--function renderMachinesPage({ machines = [], pairing = null }) {
++function renderMachinesPage({ machines = [], pairing = null, phoneReady = false }) {
const classesOf = (m) => {
const w = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).worker || {};
return Array.isArray(w.runtime_classes) ? w.runtime_classes : [];
@@@ -931,6 +968,7 @@ ${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama,
<p class="muted"><a href="https://openvibe.network/machines">All your machines are on openvibe.network/machines</a></p>
${list}
${add}
++${phoneReady ? '<p class="muted">In your pocket: <a href="/link/phone">Link on your phone</a> adds the Android app, which shows your agents\' approvals and takes files from any share sheet.</p>' : ''}
</main>`;
return page('Your machines', body, { scripts: pairing ? ['/panel/panel.js'] : [], description: 'Your own machines that run your jobs on OpenVibe.', canonical: '/machines' });
}
@@@ -1170,4 +1208,4 @@ function renderFirstRunPage({ rows = [] }) {
return page('First run', body, { description: 'The guided first run of OpenVibe Node, measured step by step.', canonical: '/admin/first-run' });
}
--module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };
++module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderPhoneLinkPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex cacd47d..467a067 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -43,7 +43,13 @@ const { frameAncestors } = require('../config');
const { BotError, json } = require('../util');
const { aiOf } = require('../machine-ai');
const kits = require('../kits');
--const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
++// The Android app's published download: an https URL on GitHub releases or openvibe.link, or nothing (the app is in
++// testing). Read on each request, so setting it in the env file and restarting is all a release needs.
++function linkApkUrl() {
++ const v = String(process.env.LINK_ANDROID_APK_URL || '').trim();
++ return /^https:\/\/(github\.com\/OpenVibers\/OpenVibe\.Node\/releases\/|openvibe\.link\/)[^\s"'<>]+$/.test(v) ? v : null;
++}
++const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderPhoneLinkPage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
const { createMachineScreen } = require('../machine-screen');
const { createScreenShares } = require('../screen-shares');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
@@@ -803,13 +809,25 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
r.get('/machines', frameCsp, page(async (req, res) => {
const me = requireUser(req);
res.setHeader('Vary', 'Cookie');
-- res.type('html').send(renderMachinesPage({ machines: await machineRows(me.subject) }));
++ res.type('html').send(renderMachinesPage({ machines: await machineRows(me.subject), phoneReady: !!linkApkUrl() }));
++ }));
++ // Link on your phone (plan 2026-11-07 §13.2): the code the Android app pairs with, and the QR it can scan. A code
++ // is minted on the visit, never for a cross-site navigation or a prefetch (the same rule as /pair/:id).
++ r.get('/link/phone', ...manage, page(async (req, res) => {
++ const me = requireUser(req);
++ if (crossSiteOrPrefetch(req)) return res.redirect(303, '/machines');
++ res.setHeader('Vary', 'Cookie');
++ // Until the Android app has a published download (LINK_ANDROID_APK_URL, the signed release), the page says it is
++ // in testing and mints no code: a QR for an app nobody can install is a dead end (Opus, 11-07).
++ const apkUrl = linkApkUrl();
++ const pairing = apkUrl ? await domain.phone.mint(me.subject) : null;
++ res.type('html').send(renderPhoneLinkPage({ pairing, apkUrl }));
}));
r.post('/machines', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
const pairing = await domain.machines.mint(me.subject);
-- res.status(201).type('html').send(renderMachinesPage({ machines: await machineRows(me.subject), pairing }));
++ res.status(201).type('html').send(renderMachinesPage({ machines: await machineRows(me.subject), pairing, phoneReady: !!linkApkUrl() }));
}));
// The owner lets their watches (OpenVibe.Watch's node source) probe from this machine, or stops them.
r.post('/machines/:id/watch-probe', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
ddiff --git a/test/phone-pairing.test.js b/test/phone-pairing.test.js
nnew file mode 100644
iindex 0000000..b2e966a
---- /dev/null
++++ b/test/phone-pairing.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++// Link on the phone (plan 2026-11-07 §13.2): a phone pairs with Network's one-time code under ref `phone:<owner>`,
++// binds on Bot as a device of kind 'phone' with no robot and its owner set, is not one of the person's machines, and
++// /link/phone shows the code and the app's own link for the QR.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const { renderPhoneLinkPage } = require('../server/web/render');
++
++(async () => {
++ const t = await boot({});
++ const alex = t.network.newUser('alex');
++
++ await check('a phone pairing binds a device of kind phone, owned by the person and serving no robot', async () => {
++ const principal = t.network.addNode({ owner: alex.subject, ref: `phone:${alex.subject}`, name: 'Pixel' });
++ const r = await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(principal) });
++ assert.strictEqual(r.status, 201, r.text);
++ const d = await t.db.maybe('SELECT * FROM devices WHERE id = $1', [r.json.device_id]);
++ assert.strictEqual(d.kind, 'phone');
++ assert.deepStrictEqual(d.robot_ids, []);
++ assert.deepStrictEqual(d.drivers, []);
++ assert.strictEqual(d.owner_subject, alex.subject);
++ assert.strictEqual(d.node_principal, principal);
++ assert.ok(r.json.robot_id == null, "a phone serves no robot");
++ });
++
++ await check('a phone is not listed among the person\'s machines', async () => {
++ const worker = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'desk' });
++ await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(worker) });
++ const list = await t.domain.machines.list(alex.subject);
++ assert.ok(list.length >= 1, 'the worker machine is there');
++ assert.ok(list.every((m) => m.kind !== 'phone'), 'the phone is not a machine');
++ });
++
++ await check('mint answers the code and the link the QR carries', async () => {
++ const p = await t.domain.phone.mint(alex.subject);
++ assert.match(p.code, /^[0-9A-Z]{4}-?[0-9A-Z]{4}$/);
++ assert.match(p.pairing_id, /^pair_/);
++ assert.strictEqual(p.qr, `openvibe-link://pair?code=${encodeURIComponent(p.code)}&pairing=${encodeURIComponent(p.pairing_id)}`, 'no server in the link: the app has its own');
++ assert.doesNotMatch(p.qr, /network=/);
++ });
++
++ await check('/link/phone is the person\'s own page, and it shows the code and the app link', async () => {
++ const guest = await t.call('GET', '/link/phone');
++ assert.ok([302, 401].includes(guest.status), `a guest got ${guest.status}`);
++ const pairing = { code: 'ABCD-1234', expires_at: new Date(Date.now() + 15 * 60000).toISOString(), qr: 'openvibe-link://pair?code=ABCD-1234&pairing=pair_01JD2C3D4E5F6G7H8J9K0MNPQR' };
++ const apkUrl = 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v0.46.0/openvibe-link.apk';
++ const html = renderPhoneLinkPage({ pairing, apkUrl });
++ assert.match(html, /ABCD-1234/);
++ assert.match(html, /qr-box/);
++ assert.match(html, /kind <b>phone<\/b>/);
++ assert.ok(html.includes(`href="${apkUrl}"`), 'the install link is the published download');
++ assert.match(html, /for the next 15 minutes \(until <time datetime="[^"]+">\d\d:\d\d UTC<\/time>\)/, 'the expiry in plain words');
++ });
++
++ await check('before the Android app is published, /link/phone says so and mints no code; /machines does not link it', async () => {
++ const html = renderPhoneLinkPage({ pairing: null, apkUrl: null });
++ assert.match(html, /in testing and not published yet/);
++ assert.doesNotMatch(html, /qr-box|class="code"/, 'no QR and no code for an app nobody can install');
++ assert.doesNotMatch(html, /releases"/, 'no link to a release that has no APK');
++ const { renderMachinesPage } = require('../server/web/render');
++ assert.doesNotMatch(renderMachinesPage({ machines: [] }), /href="\/link\/phone"/);
++ assert.match(renderMachinesPage({ machines: [], phoneReady: true }), /href="\/link\/phone"/);
++ });
++
++ done();
++})();