viveworker 0.1.1 → 0.1.3
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/LICENSE +21 -0
- package/README.md +5 -0
- package/package.json +16 -6
- package/scripts/viveworker-bridge.mjs +213 -16
- package/scripts/viveworker.mjs +81 -5
- package/web/app.css +84 -0
- package/web/app.js +216 -14
- package/web/i18n.js +30 -0
- package/web/sw.js +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yuta Hoshino
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
1
3
|
# viveworker
|
|
2
4
|
|
|
5
|
+
[](https://badge.fury.io/js/viveworker)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
3
8
|
`viveworker` brings Codex Desktop to your iPhone.
|
|
4
9
|
|
|
5
10
|
When Codex needs an approval, asks whether to implement a plan, wants you to choose from options, or finishes a task while you are away from your desk, `viveworker` keeps all of that within reach on your phone. Instead of breaking your rhythm, it helps you keep vivecoding going from anywhere in your home or office.
|
package/package.json
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viveworker",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
5
|
-
"author": "
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Local iPhone companion for Codex Desktop approvals, plan checks, questions, and notifications on your LAN.",
|
|
5
|
+
"author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
|
|
6
|
+
"license": "MIT",
|
|
6
7
|
"keywords": [
|
|
7
|
-
"vivecoding",
|
|
8
8
|
"codex",
|
|
9
|
-
"
|
|
9
|
+
"codex-desktop",
|
|
10
|
+
"macos",
|
|
11
|
+
"iphone",
|
|
12
|
+
"ios",
|
|
13
|
+
"pwa",
|
|
14
|
+
"web-push",
|
|
15
|
+
"notifications",
|
|
16
|
+
"approvals",
|
|
17
|
+
"lan",
|
|
18
|
+
"companion-app",
|
|
19
|
+
"vivecoding"
|
|
10
20
|
],
|
|
11
21
|
"homepage": "https://lp.hazbase.com/",
|
|
12
|
-
"repository": "viveworker/viveworker",
|
|
22
|
+
"repository": "viveworker-dev/viveworker",
|
|
13
23
|
"type": "module",
|
|
14
24
|
"bin": {
|
|
15
25
|
"viveworker": "./scripts/viveworker.mjs"
|
|
@@ -31,6 +31,9 @@ const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
31
31
|
const MAX_PAIRED_DEVICES = 200;
|
|
32
32
|
const PAIRING_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
|
33
33
|
const PAIRING_RATE_LIMIT_MAX_ATTEMPTS = 8;
|
|
34
|
+
const DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
|
|
35
|
+
const DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
|
|
36
|
+
const MAX_COMPLETION_REPLY_IMAGE_COUNT = 1;
|
|
34
37
|
|
|
35
38
|
const cli = parseCliArgs(process.argv.slice(2));
|
|
36
39
|
const envFile = resolveEnvFile(cli.envFile);
|
|
@@ -4406,6 +4409,7 @@ function readSession(req, config, state) {
|
|
|
4406
4409
|
pairedAtMs: Number(payload.pairedAtMs) || 0,
|
|
4407
4410
|
expiresAtMs: Number(payload.expiresAtMs) || 0,
|
|
4408
4411
|
deviceId,
|
|
4412
|
+
temporaryPairing: payload?.temporaryPairing === true,
|
|
4409
4413
|
};
|
|
4410
4414
|
}
|
|
4411
4415
|
|
|
@@ -4463,6 +4467,22 @@ function setSessionCookie(res, config) {
|
|
|
4463
4467
|
}));
|
|
4464
4468
|
}
|
|
4465
4469
|
|
|
4470
|
+
function setTemporarySessionCookie(res, config) {
|
|
4471
|
+
const secure = config.nativeApprovalPublicBaseUrl.startsWith("https://");
|
|
4472
|
+
const now = Date.now();
|
|
4473
|
+
const token = signSessionPayload({
|
|
4474
|
+
sessionId: crypto.randomUUID(),
|
|
4475
|
+
pairedAtMs: now,
|
|
4476
|
+
expiresAtMs: now + config.sessionTtlMs,
|
|
4477
|
+
temporaryPairing: true,
|
|
4478
|
+
}, config.sessionSecret);
|
|
4479
|
+
res.setHeader("Set-Cookie", buildSetCookieHeader({
|
|
4480
|
+
value: token,
|
|
4481
|
+
maxAgeSecs: Math.max(1, Math.floor(config.sessionTtlMs / 1000)),
|
|
4482
|
+
secure,
|
|
4483
|
+
}));
|
|
4484
|
+
}
|
|
4485
|
+
|
|
4466
4486
|
function clearSessionCookie(res, config) {
|
|
4467
4487
|
const secure = config.nativeApprovalPublicBaseUrl.startsWith("https://");
|
|
4468
4488
|
res.setHeader("Set-Cookie", buildSetCookieHeader({ value: "", maxAgeSecs: 0, secure }));
|
|
@@ -4620,15 +4640,27 @@ function pairingCredentialConsumed(config, state) {
|
|
|
4620
4640
|
}
|
|
4621
4641
|
|
|
4622
4642
|
function isPairingAvailableForState(config, state) {
|
|
4623
|
-
return isPairingAvailable(config) && !
|
|
4643
|
+
return isPairingAvailable(config) && !pairingCodeConsumed(config, state);
|
|
4624
4644
|
}
|
|
4625
4645
|
|
|
4626
|
-
function
|
|
4627
|
-
const
|
|
4646
|
+
function pairingCodeConsumed(config, state) {
|
|
4647
|
+
const code = cleanText(config?.pairingCode ?? "").toUpperCase();
|
|
4648
|
+
if (!code) {
|
|
4649
|
+
return false;
|
|
4650
|
+
}
|
|
4651
|
+
const consumedAtMs = Number(state?.pairingConsumedAt) || 0;
|
|
4652
|
+
const consumedCredential = cleanText(state?.pairingConsumedCredential ?? "");
|
|
4653
|
+
return consumedAtMs > 0 && consumedCredential === `code:${code}`;
|
|
4654
|
+
}
|
|
4655
|
+
|
|
4656
|
+
function markPairingConsumed(state, credential, now = Date.now()) {
|
|
4657
|
+
const current = cleanText(credential || "");
|
|
4628
4658
|
if (!current) {
|
|
4629
4659
|
return false;
|
|
4630
4660
|
}
|
|
4631
|
-
|
|
4661
|
+
const consumedAtMs = Number(state?.pairingConsumedAt) || 0;
|
|
4662
|
+
const consumedCredential = cleanText(state?.pairingConsumedCredential ?? "");
|
|
4663
|
+
if (consumedAtMs > 0 && consumedCredential === current) {
|
|
4632
4664
|
return false;
|
|
4633
4665
|
}
|
|
4634
4666
|
state.pairingConsumedAt = now;
|
|
@@ -4640,7 +4672,7 @@ function validatePairingPayload(payload, config, state) {
|
|
|
4640
4672
|
if (!config.authRequired) {
|
|
4641
4673
|
return { ok: true };
|
|
4642
4674
|
}
|
|
4643
|
-
if (!
|
|
4675
|
+
if (!isPairingAvailable(config)) {
|
|
4644
4676
|
return { ok: false, error: "pairing-unavailable" };
|
|
4645
4677
|
}
|
|
4646
4678
|
|
|
@@ -4648,10 +4680,16 @@ function validatePairingPayload(payload, config, state) {
|
|
|
4648
4680
|
const token = cleanText(payload?.token ?? "");
|
|
4649
4681
|
const matchesCode = code && cleanText(config.pairingCode).toUpperCase() === code;
|
|
4650
4682
|
const matchesToken = token && cleanText(config.pairingToken) === token;
|
|
4651
|
-
if (
|
|
4652
|
-
return { ok:
|
|
4683
|
+
if (matchesToken) {
|
|
4684
|
+
return { ok: true, credential: `token:${token}` };
|
|
4653
4685
|
}
|
|
4654
|
-
|
|
4686
|
+
if (matchesCode) {
|
|
4687
|
+
if (pairingCodeConsumed(config, state)) {
|
|
4688
|
+
return { ok: false, error: "pairing-unavailable" };
|
|
4689
|
+
}
|
|
4690
|
+
return { ok: true, credential: `code:${code}` };
|
|
4691
|
+
}
|
|
4692
|
+
return { ok: false, error: "invalid-pairing-credentials" };
|
|
4655
4693
|
}
|
|
4656
4694
|
|
|
4657
4695
|
function readRemoteAddress(req) {
|
|
@@ -5437,8 +5475,25 @@ async function submitGenericUserInputDecision({ config, runtime, state, userInpu
|
|
|
5437
5475
|
}
|
|
5438
5476
|
}
|
|
5439
5477
|
|
|
5440
|
-
|
|
5478
|
+
function normalizeCompletionReplyLocalImagePaths(paths) {
|
|
5479
|
+
if (!Array.isArray(paths)) {
|
|
5480
|
+
return [];
|
|
5481
|
+
}
|
|
5482
|
+
return paths
|
|
5483
|
+
.map((value) => resolvePath(cleanText(value || "")))
|
|
5484
|
+
.filter(Boolean);
|
|
5485
|
+
}
|
|
5486
|
+
|
|
5487
|
+
async function handleCompletionReply({
|
|
5488
|
+
runtime,
|
|
5489
|
+
completionItem,
|
|
5490
|
+
text,
|
|
5491
|
+
planMode = false,
|
|
5492
|
+
force = false,
|
|
5493
|
+
localImagePaths = [],
|
|
5494
|
+
}) {
|
|
5441
5495
|
const messageText = cleanText(text ?? "");
|
|
5496
|
+
const normalizedLocalImagePaths = normalizeCompletionReplyLocalImagePaths(localImagePaths);
|
|
5442
5497
|
if (!messageText) {
|
|
5443
5498
|
throw new Error("completion-reply-empty");
|
|
5444
5499
|
}
|
|
@@ -5472,6 +5527,10 @@ async function handleCompletionReply({ runtime, completionItem, text, planMode =
|
|
|
5472
5527
|
const turnStartParams = {
|
|
5473
5528
|
input: buildTextInput(messageText),
|
|
5474
5529
|
attachments: [],
|
|
5530
|
+
localImagePaths: normalizedLocalImagePaths,
|
|
5531
|
+
local_image_paths: normalizedLocalImagePaths,
|
|
5532
|
+
remoteImageUrls: [],
|
|
5533
|
+
remote_image_urls: [],
|
|
5475
5534
|
cwd: null,
|
|
5476
5535
|
approvalPolicy: null,
|
|
5477
5536
|
sandboxPolicy: null,
|
|
@@ -5683,7 +5742,7 @@ function resolveManifestPairingToken({ config, state, requestedToken }) {
|
|
|
5683
5742
|
if (!token) {
|
|
5684
5743
|
return "";
|
|
5685
5744
|
}
|
|
5686
|
-
if (!
|
|
5745
|
+
if (!isPairingAvailable(config)) {
|
|
5687
5746
|
return "";
|
|
5688
5747
|
}
|
|
5689
5748
|
return cleanText(config.pairingToken) === token ? token : "";
|
|
@@ -5836,6 +5895,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
5836
5895
|
httpsEnabled: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
|
|
5837
5896
|
appVersion: appPackageVersion,
|
|
5838
5897
|
deviceId: session.deviceId || null,
|
|
5898
|
+
temporaryPairing: session.temporaryPairing === true,
|
|
5839
5899
|
...buildSessionLocalePayload(config, state, session.deviceId),
|
|
5840
5900
|
});
|
|
5841
5901
|
}
|
|
@@ -5860,6 +5920,17 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
5860
5920
|
return writeJson(res, 400, { error: validation.error });
|
|
5861
5921
|
}
|
|
5862
5922
|
|
|
5923
|
+
if (payload?.temporary === true && cleanText(payload?.token || "")) {
|
|
5924
|
+
clearPairingFailures(runtime, remoteAddress);
|
|
5925
|
+
setTemporarySessionCookie(res, config);
|
|
5926
|
+
return writeJson(res, 200, {
|
|
5927
|
+
ok: true,
|
|
5928
|
+
authenticated: true,
|
|
5929
|
+
pairingAvailable: isPairingAvailableForState(config, state),
|
|
5930
|
+
temporaryPairing: true,
|
|
5931
|
+
});
|
|
5932
|
+
}
|
|
5933
|
+
|
|
5863
5934
|
const pairedDeviceId = readDeviceId(req, config) || crypto.randomUUID();
|
|
5864
5935
|
if ("detectedLocale" in payload) {
|
|
5865
5936
|
upsertDetectedDeviceLocale(state, pairedDeviceId, payload.detectedLocale);
|
|
@@ -5874,7 +5945,9 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
5874
5945
|
lastLocale: normalizeSupportedLocale(payload?.detectedLocale),
|
|
5875
5946
|
}
|
|
5876
5947
|
);
|
|
5877
|
-
|
|
5948
|
+
if (String(validation.credential || "").startsWith("code:")) {
|
|
5949
|
+
markPairingConsumed(state, validation.credential);
|
|
5950
|
+
}
|
|
5878
5951
|
clearPairingFailures(runtime, remoteAddress);
|
|
5879
5952
|
await saveState(config.stateFile, state);
|
|
5880
5953
|
setPairingCookies(res, config, pairedDeviceId);
|
|
@@ -6112,22 +6185,36 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
6112
6185
|
}
|
|
6113
6186
|
|
|
6114
6187
|
try {
|
|
6115
|
-
const
|
|
6188
|
+
const contentType = String(req.headers["content-type"] || "");
|
|
6189
|
+
const payload = contentType.includes("multipart/form-data")
|
|
6190
|
+
? await stageCompletionReplyImages(config, req)
|
|
6191
|
+
: await parseJsonBody(req);
|
|
6116
6192
|
await handleCompletionReply({
|
|
6117
6193
|
runtime,
|
|
6118
6194
|
completionItem,
|
|
6119
6195
|
text: payload?.text ?? "",
|
|
6120
6196
|
planMode: payload?.planMode === true,
|
|
6121
6197
|
force: payload?.force === true,
|
|
6198
|
+
localImagePaths: Array.isArray(payload?.localImagePaths) ? payload.localImagePaths : [],
|
|
6122
6199
|
});
|
|
6123
6200
|
return writeJson(res, 200, {
|
|
6124
6201
|
ok: true,
|
|
6125
6202
|
planMode: payload?.planMode === true,
|
|
6203
|
+
imageCount: Array.isArray(payload?.localImagePaths) ? payload.localImagePaths.length : 0,
|
|
6126
6204
|
});
|
|
6127
6205
|
} catch (error) {
|
|
6128
6206
|
if (error.message === "completion-reply-empty") {
|
|
6129
6207
|
return writeJson(res, 400, { error: error.message });
|
|
6130
6208
|
}
|
|
6209
|
+
if (
|
|
6210
|
+
error.message === "completion-reply-image-limit" ||
|
|
6211
|
+
error.message === "completion-reply-image-invalid-type" ||
|
|
6212
|
+
error.message === "completion-reply-image-too-large" ||
|
|
6213
|
+
error.message === "completion-reply-image-invalid-upload" ||
|
|
6214
|
+
error.message === "completion-reply-image-disabled"
|
|
6215
|
+
) {
|
|
6216
|
+
return writeJson(res, 400, { error: error.message });
|
|
6217
|
+
}
|
|
6131
6218
|
if (error.message === "completion-reply-unavailable") {
|
|
6132
6219
|
return writeJson(res, 409, { error: error.message });
|
|
6133
6220
|
}
|
|
@@ -6766,6 +6853,20 @@ async function parseFormBody(req) {
|
|
|
6766
6853
|
});
|
|
6767
6854
|
}
|
|
6768
6855
|
|
|
6856
|
+
async function parseMultipartBody(req) {
|
|
6857
|
+
const contentLength = Number(req.headers["content-length"]) || 0;
|
|
6858
|
+
if (contentLength > 32 * 1024 * 1024) {
|
|
6859
|
+
throw new Error("request-body-too-large");
|
|
6860
|
+
}
|
|
6861
|
+
const request = new Request("http://localhost/upload", {
|
|
6862
|
+
method: req.method || "POST",
|
|
6863
|
+
headers: req.headers,
|
|
6864
|
+
body: req,
|
|
6865
|
+
duplex: "half",
|
|
6866
|
+
});
|
|
6867
|
+
return request.formData();
|
|
6868
|
+
}
|
|
6869
|
+
|
|
6769
6870
|
async function parseJsonBody(req) {
|
|
6770
6871
|
return new Promise((resolve, reject) => {
|
|
6771
6872
|
let body = "";
|
|
@@ -6788,6 +6889,94 @@ async function parseJsonBody(req) {
|
|
|
6788
6889
|
});
|
|
6789
6890
|
}
|
|
6790
6891
|
|
|
6892
|
+
function guessUploadExtension(fileName, mimeType) {
|
|
6893
|
+
const explicitExtension = path.extname(cleanText(fileName || ""));
|
|
6894
|
+
if (explicitExtension) {
|
|
6895
|
+
return explicitExtension.toLowerCase();
|
|
6896
|
+
}
|
|
6897
|
+
const normalizedMimeType = cleanText(mimeType || "").toLowerCase();
|
|
6898
|
+
const knownExtensions = {
|
|
6899
|
+
"image/jpeg": ".jpg",
|
|
6900
|
+
"image/png": ".png",
|
|
6901
|
+
"image/webp": ".webp",
|
|
6902
|
+
"image/gif": ".gif",
|
|
6903
|
+
"image/heic": ".heic",
|
|
6904
|
+
"image/heif": ".heif",
|
|
6905
|
+
};
|
|
6906
|
+
return knownExtensions[normalizedMimeType] || ".img";
|
|
6907
|
+
}
|
|
6908
|
+
|
|
6909
|
+
async function cleanupExpiredCompletionReplyUploads(config) {
|
|
6910
|
+
try {
|
|
6911
|
+
const entries = await fs.readdir(config.replyUploadsDir, { withFileTypes: true });
|
|
6912
|
+
const cutoffMs = Date.now() - config.completionReplyUploadTtlMs;
|
|
6913
|
+
await Promise.all(entries.map(async (entry) => {
|
|
6914
|
+
if (!entry.isFile()) {
|
|
6915
|
+
return;
|
|
6916
|
+
}
|
|
6917
|
+
const filePath = path.join(config.replyUploadsDir, entry.name);
|
|
6918
|
+
try {
|
|
6919
|
+
const stat = await fs.stat(filePath);
|
|
6920
|
+
if (Number(stat.mtimeMs) < cutoffMs) {
|
|
6921
|
+
await fs.rm(filePath, { force: true });
|
|
6922
|
+
}
|
|
6923
|
+
} catch {
|
|
6924
|
+
// Ignore best-effort cleanup errors.
|
|
6925
|
+
}
|
|
6926
|
+
}));
|
|
6927
|
+
} catch {
|
|
6928
|
+
// Ignore missing upload dir.
|
|
6929
|
+
}
|
|
6930
|
+
}
|
|
6931
|
+
|
|
6932
|
+
async function stageCompletionReplyImages(config, req) {
|
|
6933
|
+
const formData = await parseMultipartBody(req);
|
|
6934
|
+
const files = formData
|
|
6935
|
+
.getAll("image")
|
|
6936
|
+
.filter((value) => typeof File !== "undefined" && value instanceof File);
|
|
6937
|
+
|
|
6938
|
+
if (files.length > 0) {
|
|
6939
|
+
throw new Error("completion-reply-image-disabled");
|
|
6940
|
+
}
|
|
6941
|
+
|
|
6942
|
+
if (files.length > MAX_COMPLETION_REPLY_IMAGE_COUNT) {
|
|
6943
|
+
throw new Error("completion-reply-image-limit");
|
|
6944
|
+
}
|
|
6945
|
+
|
|
6946
|
+
await cleanupExpiredCompletionReplyUploads(config);
|
|
6947
|
+
await fs.mkdir(config.replyUploadsDir, { recursive: true });
|
|
6948
|
+
|
|
6949
|
+
const localImagePaths = [];
|
|
6950
|
+
for (const file of files) {
|
|
6951
|
+
const mimeType = cleanText(file.type || "").toLowerCase();
|
|
6952
|
+
if (!mimeType.startsWith("image/")) {
|
|
6953
|
+
throw new Error("completion-reply-image-invalid-type");
|
|
6954
|
+
}
|
|
6955
|
+
if (!Number.isFinite(file.size) || file.size <= 0) {
|
|
6956
|
+
throw new Error("completion-reply-image-invalid-upload");
|
|
6957
|
+
}
|
|
6958
|
+
if (file.size > config.completionReplyImageMaxBytes) {
|
|
6959
|
+
throw new Error("completion-reply-image-too-large");
|
|
6960
|
+
}
|
|
6961
|
+
|
|
6962
|
+
const extension = guessUploadExtension(file.name, mimeType);
|
|
6963
|
+
const stagedFilePath = path.join(
|
|
6964
|
+
config.replyUploadsDir,
|
|
6965
|
+
`${Date.now()}-${crypto.randomUUID()}${extension}`
|
|
6966
|
+
);
|
|
6967
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
6968
|
+
await fs.writeFile(stagedFilePath, buffer, { mode: 0o600 });
|
|
6969
|
+
localImagePaths.push(stagedFilePath);
|
|
6970
|
+
}
|
|
6971
|
+
|
|
6972
|
+
return {
|
|
6973
|
+
text: cleanText(formData.get("text") ?? ""),
|
|
6974
|
+
planMode: String(formData.get("planMode") ?? "") === "true",
|
|
6975
|
+
force: String(formData.get("force") ?? "") === "true",
|
|
6976
|
+
localImagePaths,
|
|
6977
|
+
};
|
|
6978
|
+
}
|
|
6979
|
+
|
|
6791
6980
|
function isLoopbackRequest(req) {
|
|
6792
6981
|
const remoteAddress = cleanText(req.socket?.remoteAddress ?? "");
|
|
6793
6982
|
return (
|
|
@@ -7727,6 +7916,7 @@ function isLoopbackHostname(value) {
|
|
|
7727
7916
|
|
|
7728
7917
|
function buildConfig(cli) {
|
|
7729
7918
|
const codexHome = resolvePath(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
|
|
7919
|
+
const stateFile = resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json"));
|
|
7730
7920
|
return {
|
|
7731
7921
|
dryRun: cli.dryRun || truthy(process.env.DRY_RUN),
|
|
7732
7922
|
once: cli.once,
|
|
@@ -7740,7 +7930,8 @@ function buildConfig(cli) {
|
|
|
7740
7930
|
sessionIndexFile: resolvePath(process.env.SESSION_INDEX_FILE || path.join(codexHome, "session_index.jsonl")),
|
|
7741
7931
|
historyFile: resolvePath(process.env.HISTORY_FILE || path.join(codexHome, "history.jsonl")),
|
|
7742
7932
|
codexLogsDbFile: resolvePath(process.env.CODEX_LOGS_DB_FILE || ""),
|
|
7743
|
-
stateFile
|
|
7933
|
+
stateFile,
|
|
7934
|
+
replyUploadsDir: resolvePath(process.env.REPLY_UPLOADS_DIR || path.join(path.dirname(stateFile), "uploads")),
|
|
7744
7935
|
pollIntervalMs: numberEnv("POLL_INTERVAL_MS", 2500),
|
|
7745
7936
|
replaySeconds: numberEnv("REPLAY_SECONDS", 300),
|
|
7746
7937
|
sessionIndexRefreshMs: numberEnv("SESSION_INDEX_REFRESH_MS", 30000),
|
|
@@ -7794,6 +7985,14 @@ function buildConfig(cli) {
|
|
|
7794
7985
|
ipcReconnectMs: numberEnv("IPC_RECONNECT_MS", 1500),
|
|
7795
7986
|
ipcRequestTimeoutMs: numberEnv("IPC_REQUEST_TIMEOUT_MS", 12000),
|
|
7796
7987
|
choicePageSize: numberEnv("CHOICE_PAGE_SIZE", 5),
|
|
7988
|
+
completionReplyImageMaxBytes: numberEnv(
|
|
7989
|
+
"COMPLETION_REPLY_IMAGE_MAX_BYTES",
|
|
7990
|
+
DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES
|
|
7991
|
+
),
|
|
7992
|
+
completionReplyUploadTtlMs: numberEnv(
|
|
7993
|
+
"COMPLETION_REPLY_UPLOAD_TTL_MS",
|
|
7994
|
+
DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS
|
|
7995
|
+
),
|
|
7797
7996
|
deviceTrustTtlMs: numberEnv("DEVICE_TRUST_TTL_MS", DEFAULT_DEVICE_TRUST_TTL_MS),
|
|
7798
7997
|
sessionTtlMs: numberEnv("SESSION_TTL_MS", 30 * 24 * 60 * 60 * 1000),
|
|
7799
7998
|
pairingCode: process.env.PAIRING_CODE || "",
|
|
@@ -7907,9 +8106,7 @@ function loadEnvFile(filePath) {
|
|
|
7907
8106
|
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
7908
8107
|
value = value.slice(1, -1);
|
|
7909
8108
|
}
|
|
7910
|
-
|
|
7911
|
-
process.env[key] = value;
|
|
7912
|
-
}
|
|
8109
|
+
process.env[key] = value;
|
|
7913
8110
|
}
|
|
7914
8111
|
} catch {
|
|
7915
8112
|
// Optional env file.
|
package/scripts/viveworker.mjs
CHANGED
|
@@ -200,7 +200,14 @@ async function runSetup(cliOptions) {
|
|
|
200
200
|
|
|
201
201
|
progress.update("cli.setup.progress.health");
|
|
202
202
|
const healthy = await waitForHealth(buildLoopbackHealthUrl(publicBaseUrl));
|
|
203
|
-
|
|
203
|
+
const pairingReady = healthy
|
|
204
|
+
? await waitForExpectedPairing(publicBaseUrl, pairToken)
|
|
205
|
+
: false;
|
|
206
|
+
progress.done(healthy && pairingReady ? "cli.setup.complete" : "cli.setup.completePending");
|
|
207
|
+
if (healthy && !pairingReady) {
|
|
208
|
+
console.log("");
|
|
209
|
+
console.log(t(locale, "cli.setup.warning.stalePairingServer", { port }));
|
|
210
|
+
}
|
|
204
211
|
|
|
205
212
|
const pairPath = `/app?pairToken=${encodeURIComponent(pairToken)}`;
|
|
206
213
|
const mkcertRootCaFile = resolvePath(
|
|
@@ -258,7 +265,7 @@ async function runSetup(cliOptions) {
|
|
|
258
265
|
if (allowInsecureHttpLan) {
|
|
259
266
|
console.log(t(locale, "cli.setup.warning.insecureHttpLan"));
|
|
260
267
|
}
|
|
261
|
-
if (canShowCaDownload && !cliOptions.installMkcert) {
|
|
268
|
+
if (canShowCaDownload && !cliOptions.installMkcert && !cliOptions.pair) {
|
|
262
269
|
console.log(t(locale, "cli.setup.caDownloadLocal", { url: caDownloadLocalUrl }));
|
|
263
270
|
console.log(t(locale, "cli.setup.caDownloadIp", { url: caDownloadIpUrl }));
|
|
264
271
|
}
|
|
@@ -273,7 +280,7 @@ async function runSetup(cliOptions) {
|
|
|
273
280
|
console.log("");
|
|
274
281
|
console.log(t(locale, "cli.setup.qrPairing"));
|
|
275
282
|
await printQrCode(`${publicBaseUrl}${pairPath}`);
|
|
276
|
-
if (canShowCaDownload && !cliOptions.installMkcert) {
|
|
283
|
+
if (canShowCaDownload && !cliOptions.installMkcert && !cliOptions.pair) {
|
|
277
284
|
console.log("");
|
|
278
285
|
console.log(t(locale, "cli.setup.qrCaDownload"));
|
|
279
286
|
await printQrCode(caDownloadIpUrl);
|
|
@@ -309,7 +316,14 @@ async function runStart(cliOptions) {
|
|
|
309
316
|
await execCommand(["launchctl", "kickstart", "-k", `gui/${process.getuid()}/${defaultLabel}`]);
|
|
310
317
|
progress.update("cli.start.progress.health");
|
|
311
318
|
const healthy = await waitForHealth(healthUrl);
|
|
312
|
-
|
|
319
|
+
const pairingReady = healthy && rotatedPairing.rotated
|
|
320
|
+
? await waitForExpectedPairing(config.NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL || "", rotatedPairing.pairingToken)
|
|
321
|
+
: true;
|
|
322
|
+
progress.done(healthy && pairingReady ? "cli.start.launchdStarted" : "cli.start.launchdStartedPending");
|
|
323
|
+
if (healthy && !pairingReady) {
|
|
324
|
+
console.log("");
|
|
325
|
+
console.log(t(locale, "cli.setup.warning.stalePairingServer", { port: config.NATIVE_APPROVAL_SERVER_PORT || defaultServerPort }));
|
|
326
|
+
}
|
|
313
327
|
if (rotatedPairing.rotated) {
|
|
314
328
|
await printPairingInfo(locale, config);
|
|
315
329
|
}
|
|
@@ -324,7 +338,14 @@ async function runStart(cliOptions) {
|
|
|
324
338
|
});
|
|
325
339
|
progress.update("cli.start.progress.health");
|
|
326
340
|
const healthy = await waitForHealth(healthUrl);
|
|
327
|
-
|
|
341
|
+
const pairingReady = healthy && rotatedPairing.rotated
|
|
342
|
+
? await waitForExpectedPairing(config.NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL || "", rotatedPairing.pairingToken)
|
|
343
|
+
: true;
|
|
344
|
+
progress.done(healthy && pairingReady ? "cli.start.bridgeStarted" : "cli.start.bridgeStartedPending");
|
|
345
|
+
if (healthy && !pairingReady) {
|
|
346
|
+
console.log("");
|
|
347
|
+
console.log(t(locale, "cli.setup.warning.stalePairingServer", { port: config.NATIVE_APPROVAL_SERVER_PORT || defaultServerPort }));
|
|
348
|
+
}
|
|
328
349
|
if (rotatedPairing.rotated) {
|
|
329
350
|
await printPairingInfo(locale, config);
|
|
330
351
|
}
|
|
@@ -982,6 +1003,33 @@ function buildLoopbackHealthUrl(baseUrl) {
|
|
|
982
1003
|
}
|
|
983
1004
|
}
|
|
984
1005
|
|
|
1006
|
+
function buildLoopbackUrl(baseUrl, pathname, searchParams = null) {
|
|
1007
|
+
if (!baseUrl) {
|
|
1008
|
+
return "";
|
|
1009
|
+
}
|
|
1010
|
+
try {
|
|
1011
|
+
const url = new URL(baseUrl);
|
|
1012
|
+
url.hostname = "127.0.0.1";
|
|
1013
|
+
url.pathname = pathname;
|
|
1014
|
+
url.search = "";
|
|
1015
|
+
url.hash = "";
|
|
1016
|
+
if (searchParams && Object.keys(searchParams).length > 0) {
|
|
1017
|
+
const params = new URLSearchParams();
|
|
1018
|
+
for (const [key, value] of Object.entries(searchParams)) {
|
|
1019
|
+
if (value == null || value === "") {
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
params.set(key, String(value));
|
|
1023
|
+
}
|
|
1024
|
+
const serialized = params.toString();
|
|
1025
|
+
url.search = serialized ? `?${serialized}` : "";
|
|
1026
|
+
}
|
|
1027
|
+
return url.toString();
|
|
1028
|
+
} catch {
|
|
1029
|
+
return "";
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
985
1033
|
function sleep(ms) {
|
|
986
1034
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
987
1035
|
}
|
|
@@ -1105,6 +1153,34 @@ async function waitForHealth(url, { attempts = 8, intervalMs = 500 } = {}) {
|
|
|
1105
1153
|
return false;
|
|
1106
1154
|
}
|
|
1107
1155
|
|
|
1156
|
+
async function waitForExpectedPairing(baseUrl, pairToken, { attempts = 8, intervalMs = 500 } = {}) {
|
|
1157
|
+
const token = String(pairToken || "").trim();
|
|
1158
|
+
const manifestUrl = buildLoopbackUrl(baseUrl, "/manifest.webmanifest", { pairToken: token });
|
|
1159
|
+
const expectedStartUrl = `/app?pairToken=${encodeURIComponent(token)}`;
|
|
1160
|
+
if (!token || !manifestUrl) {
|
|
1161
|
+
return false;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1165
|
+
const result = await execCommand(buildHealthCheckArgs(manifestUrl), { ignoreError: true });
|
|
1166
|
+
if (result.ok) {
|
|
1167
|
+
try {
|
|
1168
|
+
const payload = JSON.parse(result.stdout);
|
|
1169
|
+
if (String(payload?.start_url || "").trim() === expectedStartUrl) {
|
|
1170
|
+
return true;
|
|
1171
|
+
}
|
|
1172
|
+
} catch {
|
|
1173
|
+
// Keep retrying while the new bridge instance comes up.
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
if (attempt < attempts - 1) {
|
|
1177
|
+
await sleep(intervalMs);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
return false;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1108
1184
|
function truthyString(value) {
|
|
1109
1185
|
return /^(1|true|yes|on)$/iu.test(String(value || "").trim());
|
|
1110
1186
|
}
|
package/web/app.css
CHANGED
|
@@ -1541,6 +1541,90 @@ code {
|
|
|
1541
1541
|
color: rgba(205, 220, 231, 0.42);
|
|
1542
1542
|
}
|
|
1543
1543
|
|
|
1544
|
+
.reply-attachment-field {
|
|
1545
|
+
display: grid;
|
|
1546
|
+
gap: 0.55rem;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
.reply-attachment-field__header {
|
|
1550
|
+
display: flex;
|
|
1551
|
+
align-items: center;
|
|
1552
|
+
justify-content: space-between;
|
|
1553
|
+
gap: 0.75rem;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
.reply-attachment-picker {
|
|
1557
|
+
display: grid;
|
|
1558
|
+
gap: 0.18rem;
|
|
1559
|
+
padding: 0.88rem 0.95rem;
|
|
1560
|
+
border-radius: 18px;
|
|
1561
|
+
border: 1px dashed rgba(156, 181, 197, 0.24);
|
|
1562
|
+
background: rgba(255, 255, 255, 0.03);
|
|
1563
|
+
cursor: pointer;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
.reply-attachment-picker__input {
|
|
1567
|
+
position: absolute;
|
|
1568
|
+
opacity: 0;
|
|
1569
|
+
pointer-events: none;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
.reply-attachment-picker__label {
|
|
1573
|
+
font-size: 0.93rem;
|
|
1574
|
+
font-weight: 700;
|
|
1575
|
+
color: #eef8ff;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
.reply-attachment-picker__hint {
|
|
1579
|
+
font-size: 0.82rem;
|
|
1580
|
+
color: var(--muted);
|
|
1581
|
+
line-height: 1.42;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
.reply-image-preview {
|
|
1585
|
+
display: grid;
|
|
1586
|
+
grid-template-columns: 4.5rem minmax(0, 1fr);
|
|
1587
|
+
gap: 0.8rem;
|
|
1588
|
+
align-items: center;
|
|
1589
|
+
padding: 0.78rem 0.85rem;
|
|
1590
|
+
border-radius: 18px;
|
|
1591
|
+
border: 1px solid rgba(156, 181, 197, 0.12);
|
|
1592
|
+
background: rgba(255, 255, 255, 0.04);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
.reply-image-preview__image {
|
|
1596
|
+
width: 4.5rem;
|
|
1597
|
+
height: 4.5rem;
|
|
1598
|
+
object-fit: cover;
|
|
1599
|
+
border-radius: 14px;
|
|
1600
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
1601
|
+
background: rgba(6, 10, 14, 0.92);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
.reply-image-preview__copy {
|
|
1605
|
+
min-width: 0;
|
|
1606
|
+
display: grid;
|
|
1607
|
+
gap: 0.14rem;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
.reply-image-preview__name,
|
|
1611
|
+
.reply-image-preview__meta {
|
|
1612
|
+
margin: 0;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
.reply-image-preview__name {
|
|
1616
|
+
color: #eef8ff;
|
|
1617
|
+
font-size: 0.91rem;
|
|
1618
|
+
font-weight: 600;
|
|
1619
|
+
line-height: 1.35;
|
|
1620
|
+
overflow-wrap: anywhere;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
.reply-image-preview__meta {
|
|
1624
|
+
color: var(--muted);
|
|
1625
|
+
font-size: 0.8rem;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1544
1628
|
.reply-mode-switch {
|
|
1545
1629
|
display: grid;
|
|
1546
1630
|
grid-template-columns: auto auto minmax(0, 1fr);
|
package/web/app.js
CHANGED
|
@@ -87,9 +87,12 @@ async function boot() {
|
|
|
87
87
|
|
|
88
88
|
await refreshSession();
|
|
89
89
|
|
|
90
|
-
if (!state.session?.authenticated && initialPairToken) {
|
|
90
|
+
if (!state.session?.authenticated && initialPairToken && shouldAutoPairFromBootstrapToken()) {
|
|
91
91
|
try {
|
|
92
|
-
await pair({
|
|
92
|
+
await pair({
|
|
93
|
+
token: initialPairToken,
|
|
94
|
+
temporary: shouldUseTemporaryBootstrapPairing(),
|
|
95
|
+
});
|
|
93
96
|
} catch (error) {
|
|
94
97
|
state.pairError = error.message || String(error);
|
|
95
98
|
}
|
|
@@ -480,6 +483,7 @@ function syncCompletedThreadFilter() {
|
|
|
480
483
|
}
|
|
481
484
|
|
|
482
485
|
function renderPair() {
|
|
486
|
+
const shouldInstallFromHomeScreen = Boolean(initialPairToken) && !shouldAutoPairFromBootstrapToken();
|
|
483
487
|
app.innerHTML = `
|
|
484
488
|
<main class="onboarding-shell">
|
|
485
489
|
<section class="onboarding-card">
|
|
@@ -488,6 +492,7 @@ function renderPair() {
|
|
|
488
492
|
<p class="hero-copy">${escapeHtml(L("pair.copy"))}</p>
|
|
489
493
|
${state.pairNotice ? `<p class="inline-alert inline-alert--success">${escapeHtml(state.pairNotice)}</p>` : ""}
|
|
490
494
|
${state.pairError ? `<p class="inline-alert inline-alert--danger">${escapeHtml(state.pairError)}</p>` : ""}
|
|
495
|
+
${shouldInstallFromHomeScreen ? `<p class="inline-alert inline-alert--warning">${escapeHtml(L("pair.installFromHomeScreen"))}</p>` : ""}
|
|
491
496
|
<form id="pair-form" class="pair-form">
|
|
492
497
|
<label class="field">
|
|
493
498
|
<span class="field-label">${escapeHtml(L("pair.codeLabel"))}</span>
|
|
@@ -530,7 +535,9 @@ function renderPair() {
|
|
|
530
535
|
|
|
531
536
|
async function pair(payload) {
|
|
532
537
|
const result = await apiPost("/api/session/pair", payload);
|
|
533
|
-
|
|
538
|
+
if (result?.temporaryPairing !== true) {
|
|
539
|
+
syncPairingTokenState("");
|
|
540
|
+
}
|
|
534
541
|
return result;
|
|
535
542
|
}
|
|
536
543
|
|
|
@@ -555,6 +562,7 @@ function resetAuthenticatedState() {
|
|
|
555
562
|
state.detailLoadingItem = null;
|
|
556
563
|
state.detailOpen = false;
|
|
557
564
|
state.choiceLocalDrafts = {};
|
|
565
|
+
clearAllCompletionReplyDrafts();
|
|
558
566
|
state.completionReplyDrafts = {};
|
|
559
567
|
state.settingsSubpage = "";
|
|
560
568
|
state.settingsScrollState = null;
|
|
@@ -765,11 +773,70 @@ function normalizeReplyMode(value) {
|
|
|
765
773
|
return normalizeClientText(value).toLowerCase() === "plan" ? "plan" : "default";
|
|
766
774
|
}
|
|
767
775
|
|
|
776
|
+
const COMPLETION_REPLY_IMAGE_SUPPORT = false;
|
|
777
|
+
|
|
778
|
+
function normalizeCompletionReplyAttachment(value) {
|
|
779
|
+
if (!COMPLETION_REPLY_IMAGE_SUPPORT) {
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
if (!value || typeof value !== "object") {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
const file = typeof File !== "undefined" && value.file instanceof File ? value.file : null;
|
|
786
|
+
const name = normalizeClientText(value.name || file?.name || "");
|
|
787
|
+
const type = normalizeClientText(value.type || file?.type || "");
|
|
788
|
+
const size = Number(value.size ?? file?.size) || 0;
|
|
789
|
+
const previewUrl = normalizeClientText(value.previewUrl || "");
|
|
790
|
+
if (!file || !name || !type.startsWith("image/") || size <= 0) {
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
file,
|
|
795
|
+
name,
|
|
796
|
+
type,
|
|
797
|
+
size,
|
|
798
|
+
previewUrl,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function createCompletionReplyAttachment(file) {
|
|
803
|
+
if (!COMPLETION_REPLY_IMAGE_SUPPORT) {
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
if (!(typeof File !== "undefined" && file instanceof File)) {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
if (!normalizeClientText(file.type).startsWith("image/")) {
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
return normalizeCompletionReplyAttachment({
|
|
813
|
+
file,
|
|
814
|
+
name: file.name,
|
|
815
|
+
type: file.type,
|
|
816
|
+
size: file.size,
|
|
817
|
+
previewUrl: typeof URL !== "undefined" && typeof URL.createObjectURL === "function"
|
|
818
|
+
? URL.createObjectURL(file)
|
|
819
|
+
: "",
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function releaseCompletionReplyAttachment(attachment) {
|
|
824
|
+
if (!attachment?.previewUrl || typeof URL === "undefined" || typeof URL.revokeObjectURL !== "function") {
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
try {
|
|
828
|
+
URL.revokeObjectURL(attachment.previewUrl);
|
|
829
|
+
} catch {
|
|
830
|
+
// Ignore best-effort object URL cleanup errors.
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
768
834
|
function getCompletionReplyDraft(token) {
|
|
769
835
|
if (!token) {
|
|
770
836
|
return {
|
|
771
837
|
text: "",
|
|
772
838
|
sentText: "",
|
|
839
|
+
attachment: null,
|
|
773
840
|
mode: "default",
|
|
774
841
|
notice: "",
|
|
775
842
|
error: "",
|
|
@@ -784,6 +851,7 @@ function getCompletionReplyDraft(token) {
|
|
|
784
851
|
return {
|
|
785
852
|
text: String(draft.text ?? ""),
|
|
786
853
|
sentText: normalizeClientText(draft.sentText ?? ""),
|
|
854
|
+
attachment: normalizeCompletionReplyAttachment(draft.attachment),
|
|
787
855
|
mode: normalizeReplyMode(draft.mode),
|
|
788
856
|
notice: normalizeClientText(draft.notice),
|
|
789
857
|
error: normalizeClientText(draft.error),
|
|
@@ -815,13 +883,22 @@ function setCompletionReplyDraft(token, partialDraft) {
|
|
|
815
883
|
if (!token) {
|
|
816
884
|
return;
|
|
817
885
|
}
|
|
886
|
+
const previousStoredDraft = state.completionReplyDrafts?.[token] || {};
|
|
887
|
+
const previousAttachment = normalizeCompletionReplyAttachment(previousStoredDraft.attachment);
|
|
818
888
|
const nextDraft = {
|
|
819
889
|
...getCompletionReplyDraft(token),
|
|
820
890
|
...(partialDraft || {}),
|
|
821
891
|
};
|
|
892
|
+
const nextAttachment = Object.prototype.hasOwnProperty.call(partialDraft || {}, "attachment")
|
|
893
|
+
? normalizeCompletionReplyAttachment(partialDraft?.attachment)
|
|
894
|
+
: normalizeCompletionReplyAttachment(nextDraft.attachment);
|
|
895
|
+
if (previousAttachment?.previewUrl && previousAttachment.previewUrl !== nextAttachment?.previewUrl) {
|
|
896
|
+
releaseCompletionReplyAttachment(previousAttachment);
|
|
897
|
+
}
|
|
822
898
|
state.completionReplyDrafts[token] = {
|
|
823
899
|
text: String(nextDraft.text ?? ""),
|
|
824
900
|
sentText: normalizeClientText(nextDraft.sentText ?? ""),
|
|
901
|
+
attachment: nextAttachment,
|
|
825
902
|
mode: normalizeReplyMode(nextDraft.mode),
|
|
826
903
|
notice: normalizeClientText(nextDraft.notice),
|
|
827
904
|
error: normalizeClientText(nextDraft.error),
|
|
@@ -836,9 +913,16 @@ function clearCompletionReplyDraft(token) {
|
|
|
836
913
|
if (!token || !state.completionReplyDrafts?.[token]) {
|
|
837
914
|
return;
|
|
838
915
|
}
|
|
916
|
+
releaseCompletionReplyAttachment(state.completionReplyDrafts[token]?.attachment);
|
|
839
917
|
delete state.completionReplyDrafts[token];
|
|
840
918
|
}
|
|
841
919
|
|
|
920
|
+
function clearAllCompletionReplyDrafts() {
|
|
921
|
+
for (const token of Object.keys(state.completionReplyDrafts || {})) {
|
|
922
|
+
clearCompletionReplyDraft(token);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
842
926
|
function syncCompletionReplyComposerLiveState(replyForm, draft) {
|
|
843
927
|
if (!replyForm) {
|
|
844
928
|
return;
|
|
@@ -2210,6 +2294,8 @@ function renderCompletionReplyComposer(detail, options = {}) {
|
|
|
2210
2294
|
const warningTimestamp = draft.warning?.createdAtMs ? formatTimelineTimestamp(draft.warning.createdAtMs) : "";
|
|
2211
2295
|
const showCollapsedState =
|
|
2212
2296
|
draft.collapsedAfterSend && Boolean(draft.notice) && !draft.error && !draft.warning && !draft.sending;
|
|
2297
|
+
const attachmentName = draft.attachment?.name ? escapeHtml(draft.attachment.name) : "";
|
|
2298
|
+
const attachmentPreviewUrl = draft.attachment?.previewUrl ? escapeHtml(draft.attachment.previewUrl) : "";
|
|
2213
2299
|
|
|
2214
2300
|
return `
|
|
2215
2301
|
<section class="detail-card detail-card--reply ${options.mobile ? "detail-card--mobile" : ""}">
|
|
@@ -2275,6 +2361,55 @@ function renderCompletionReplyComposer(detail, options = {}) {
|
|
|
2275
2361
|
data-reply-token="${escapeHtml(detail.token)}"
|
|
2276
2362
|
>${escapeHtml(draft.text)}</textarea>
|
|
2277
2363
|
</label>
|
|
2364
|
+
${
|
|
2365
|
+
detail.reply?.supportsImages
|
|
2366
|
+
? `
|
|
2367
|
+
<div class="reply-attachment-field">
|
|
2368
|
+
<div class="reply-attachment-field__header">
|
|
2369
|
+
<span class="field-label">${escapeHtml(L("reply.imageLabel"))}</span>
|
|
2370
|
+
${
|
|
2371
|
+
draft.attachment
|
|
2372
|
+
? `
|
|
2373
|
+
<button
|
|
2374
|
+
class="secondary secondary--compact"
|
|
2375
|
+
type="button"
|
|
2376
|
+
data-reply-image-remove
|
|
2377
|
+
data-reply-token="${escapeHtml(detail.token)}"
|
|
2378
|
+
>
|
|
2379
|
+
${escapeHtml(L("reply.imageRemove"))}
|
|
2380
|
+
</button>
|
|
2381
|
+
`
|
|
2382
|
+
: ""
|
|
2383
|
+
}
|
|
2384
|
+
</div>
|
|
2385
|
+
<label class="reply-attachment-picker">
|
|
2386
|
+
<input
|
|
2387
|
+
class="reply-attachment-picker__input"
|
|
2388
|
+
type="file"
|
|
2389
|
+
accept="image/*"
|
|
2390
|
+
data-reply-image-input
|
|
2391
|
+
data-reply-token="${escapeHtml(detail.token)}"
|
|
2392
|
+
>
|
|
2393
|
+
<span class="reply-attachment-picker__label">${escapeHtml(L(draft.attachment ? "reply.imageReplace" : "reply.imageAdd"))}</span>
|
|
2394
|
+
<span class="reply-attachment-picker__hint">${escapeHtml(L("reply.imageHint"))}</span>
|
|
2395
|
+
</label>
|
|
2396
|
+
${
|
|
2397
|
+
draft.attachment
|
|
2398
|
+
? `
|
|
2399
|
+
<div class="reply-image-preview">
|
|
2400
|
+
<img class="reply-image-preview__image" src="${attachmentPreviewUrl}" alt="${attachmentName}">
|
|
2401
|
+
<div class="reply-image-preview__copy">
|
|
2402
|
+
<p class="reply-image-preview__name">${attachmentName}</p>
|
|
2403
|
+
<p class="reply-image-preview__meta">${escapeHtml(L("reply.imageAttached"))}</p>
|
|
2404
|
+
</div>
|
|
2405
|
+
</div>
|
|
2406
|
+
`
|
|
2407
|
+
: ""
|
|
2408
|
+
}
|
|
2409
|
+
</div>
|
|
2410
|
+
`
|
|
2411
|
+
: ""
|
|
2412
|
+
}
|
|
2278
2413
|
${
|
|
2279
2414
|
detail.reply?.supportsPlanMode
|
|
2280
2415
|
? `
|
|
@@ -2841,6 +2976,46 @@ function bindShellInteractions() {
|
|
|
2841
2976
|
});
|
|
2842
2977
|
}
|
|
2843
2978
|
|
|
2979
|
+
for (const input of document.querySelectorAll("[data-reply-image-input][data-reply-token]")) {
|
|
2980
|
+
input.addEventListener("change", async () => {
|
|
2981
|
+
const token = input.dataset.replyToken || "";
|
|
2982
|
+
const [file] = Array.from(input.files || []);
|
|
2983
|
+
const nextAttachment = createCompletionReplyAttachment(file);
|
|
2984
|
+
if (!nextAttachment && file) {
|
|
2985
|
+
setCompletionReplyDraft(token, {
|
|
2986
|
+
error: L("error.completionReplyImageInvalidType"),
|
|
2987
|
+
notice: "",
|
|
2988
|
+
warning: null,
|
|
2989
|
+
confirmOverride: false,
|
|
2990
|
+
});
|
|
2991
|
+
await renderShell();
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
setCompletionReplyDraft(token, {
|
|
2995
|
+
attachment: nextAttachment,
|
|
2996
|
+
notice: "",
|
|
2997
|
+
error: "",
|
|
2998
|
+
warning: null,
|
|
2999
|
+
confirmOverride: false,
|
|
3000
|
+
});
|
|
3001
|
+
await renderShell();
|
|
3002
|
+
});
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
for (const button of document.querySelectorAll("[data-reply-image-remove][data-reply-token]")) {
|
|
3006
|
+
button.addEventListener("click", async () => {
|
|
3007
|
+
const token = button.dataset.replyToken || "";
|
|
3008
|
+
setCompletionReplyDraft(token, {
|
|
3009
|
+
attachment: null,
|
|
3010
|
+
notice: "",
|
|
3011
|
+
error: "",
|
|
3012
|
+
warning: null,
|
|
3013
|
+
confirmOverride: false,
|
|
3014
|
+
});
|
|
3015
|
+
await renderShell();
|
|
3016
|
+
});
|
|
3017
|
+
}
|
|
3018
|
+
|
|
2844
3019
|
for (const button of document.querySelectorAll("[data-open-logout-confirm]")) {
|
|
2845
3020
|
button.addEventListener("click", async () => {
|
|
2846
3021
|
state.logoutConfirmOpen = true;
|
|
@@ -3010,14 +3185,18 @@ function bindShellInteractions() {
|
|
|
3010
3185
|
await renderShell();
|
|
3011
3186
|
|
|
3012
3187
|
try {
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3188
|
+
const requestBody = new FormData();
|
|
3189
|
+
requestBody.set("text", text);
|
|
3190
|
+
requestBody.set("planMode", draft.mode === "plan" ? "true" : "false");
|
|
3191
|
+
requestBody.set("force", draft.confirmOverride === true ? "true" : "false");
|
|
3192
|
+
if (COMPLETION_REPLY_IMAGE_SUPPORT && draft.attachment?.file) {
|
|
3193
|
+
requestBody.append("image", draft.attachment.file, draft.attachment.name || draft.attachment.file.name);
|
|
3194
|
+
}
|
|
3195
|
+
await apiPost(`/api/items/completion/${encodeURIComponent(token)}/reply`, requestBody);
|
|
3018
3196
|
setCompletionReplyDraft(token, {
|
|
3019
3197
|
text: "",
|
|
3020
3198
|
sentText: text,
|
|
3199
|
+
attachment: null,
|
|
3021
3200
|
mode: draft.mode,
|
|
3022
3201
|
sending: false,
|
|
3023
3202
|
error: "",
|
|
@@ -3032,6 +3211,7 @@ function bindShellInteractions() {
|
|
|
3032
3211
|
setCompletionReplyDraft(token, {
|
|
3033
3212
|
text,
|
|
3034
3213
|
sentText: "",
|
|
3214
|
+
attachment: draft.attachment,
|
|
3035
3215
|
mode: draft.mode,
|
|
3036
3216
|
sending: false,
|
|
3037
3217
|
notice: "",
|
|
@@ -3046,6 +3226,7 @@ function bindShellInteractions() {
|
|
|
3046
3226
|
setCompletionReplyDraft(token, {
|
|
3047
3227
|
text,
|
|
3048
3228
|
sentText: "",
|
|
3229
|
+
attachment: draft.attachment,
|
|
3049
3230
|
mode: draft.mode,
|
|
3050
3231
|
sending: false,
|
|
3051
3232
|
notice: "",
|
|
@@ -3759,14 +3940,19 @@ async function apiGet(url) {
|
|
|
3759
3940
|
}
|
|
3760
3941
|
|
|
3761
3942
|
async function apiPost(url, body) {
|
|
3943
|
+
const isFormDataBody = typeof FormData !== "undefined" && body instanceof FormData;
|
|
3762
3944
|
const response = await fetch(url, {
|
|
3763
3945
|
method: "POST",
|
|
3764
3946
|
credentials: "same-origin",
|
|
3765
|
-
headers:
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3947
|
+
headers: isFormDataBody
|
|
3948
|
+
? {
|
|
3949
|
+
Accept: "application/json",
|
|
3950
|
+
}
|
|
3951
|
+
: {
|
|
3952
|
+
"Content-Type": "application/json",
|
|
3953
|
+
Accept: "application/json",
|
|
3954
|
+
},
|
|
3955
|
+
body: isFormDataBody ? body : JSON.stringify(body || {}),
|
|
3770
3956
|
});
|
|
3771
3957
|
if (!response.ok) {
|
|
3772
3958
|
const errorInfo = await readError(response);
|
|
@@ -3809,6 +3995,11 @@ function localizeApiError(value) {
|
|
|
3809
3995
|
"completion-reply-unavailable": "error.completionReplyUnavailable",
|
|
3810
3996
|
"completion-reply-thread-advanced": "error.completionReplyThreadAdvanced",
|
|
3811
3997
|
"completion-reply-empty": "error.completionReplyEmpty",
|
|
3998
|
+
"completion-reply-image-invalid-type": "error.completionReplyImageInvalidType",
|
|
3999
|
+
"completion-reply-image-too-large": "error.completionReplyImageTooLarge",
|
|
4000
|
+
"completion-reply-image-limit": "error.completionReplyImageLimit",
|
|
4001
|
+
"completion-reply-image-invalid-upload": "error.completionReplyImageInvalidUpload",
|
|
4002
|
+
"completion-reply-image-disabled": "error.completionReplyImageDisabled",
|
|
3812
4003
|
"codex-ipc-not-connected": "error.codexIpcNotConnected",
|
|
3813
4004
|
"approval-not-found": "error.approvalNotFound",
|
|
3814
4005
|
"approval-already-handled": "error.approvalAlreadyHandled",
|
|
@@ -3883,12 +4074,23 @@ function syncPairingTokenState(pairToken) {
|
|
|
3883
4074
|
}
|
|
3884
4075
|
|
|
3885
4076
|
function desiredBootstrapPairingToken() {
|
|
3886
|
-
if (state.session?.authenticated) {
|
|
4077
|
+
if (state.session?.authenticated && !state.session?.temporaryPairing) {
|
|
3887
4078
|
return "";
|
|
3888
4079
|
}
|
|
3889
4080
|
return initialPairToken;
|
|
3890
4081
|
}
|
|
3891
4082
|
|
|
4083
|
+
function shouldAutoPairFromBootstrapToken() {
|
|
4084
|
+
if (!initialPairToken) {
|
|
4085
|
+
return false;
|
|
4086
|
+
}
|
|
4087
|
+
return true;
|
|
4088
|
+
}
|
|
4089
|
+
|
|
4090
|
+
function shouldUseTemporaryBootstrapPairing() {
|
|
4091
|
+
return Boolean(initialPairToken) && !isStandaloneMode() && isProbablySafari();
|
|
4092
|
+
}
|
|
4093
|
+
|
|
3892
4094
|
function urlBase64ToUint8Array(base64String) {
|
|
3893
4095
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
|
3894
4096
|
const normalized = `${base64String}${padding}`.replace(/-/gu, "+").replace(/_/gu, "/");
|
package/web/i18n.js
CHANGED
|
@@ -70,6 +70,8 @@ const translations = {
|
|
|
70
70
|
"pair.codeLabel": "Pairing code",
|
|
71
71
|
"pair.codePlaceholder": "Enter the pairing code",
|
|
72
72
|
"pair.connect": "Connect this iPhone",
|
|
73
|
+
"pair.installFromHomeScreen":
|
|
74
|
+
"This pairing link is being kept for the Home Screen app. Add viveworker to your Home Screen from Safari, then open the new icon to finish pairing there.",
|
|
73
75
|
"pair.helperTitle": "Add to Home Screen",
|
|
74
76
|
"pair.helperCopy": "Install viveworker for the best mobile layout and Web Push support.",
|
|
75
77
|
"banner.install.title": "Add viveworker to your Home Screen",
|
|
@@ -112,6 +114,12 @@ const translations = {
|
|
|
112
114
|
"reply.copy": "Send a new message back into this Codex thread from the latest completed result.",
|
|
113
115
|
"reply.fieldLabel": "Message",
|
|
114
116
|
"reply.placeholder": "Ask Codex to continue, refine the result, or try a different approach.",
|
|
117
|
+
"reply.imageLabel": "Image",
|
|
118
|
+
"reply.imageAdd": "Add image",
|
|
119
|
+
"reply.imageReplace": "Replace image",
|
|
120
|
+
"reply.imageRemove": "Remove",
|
|
121
|
+
"reply.imageHint": "Attach one image from your iPhone and send it together with your message.",
|
|
122
|
+
"reply.imageAttached": "Attached image",
|
|
115
123
|
"reply.send": "Send to Codex",
|
|
116
124
|
"reply.sendConfirm": "Send anyway",
|
|
117
125
|
"reply.sendAnother": "Send another message",
|
|
@@ -283,6 +291,11 @@ const translations = {
|
|
|
283
291
|
"error.completionReplyUnavailable": "This completed item can no longer send a follow-up.",
|
|
284
292
|
"error.completionReplyThreadAdvanced": "This thread already has newer messages.",
|
|
285
293
|
"error.completionReplyEmpty": "Enter a message before sending it.",
|
|
294
|
+
"error.completionReplyImageInvalidType": "Choose an image file to attach.",
|
|
295
|
+
"error.completionReplyImageTooLarge": "That image is too large to send from this reply screen.",
|
|
296
|
+
"error.completionReplyImageLimit": "Attach one image at a time for now.",
|
|
297
|
+
"error.completionReplyImageInvalidUpload": "That image could not be uploaded.",
|
|
298
|
+
"error.completionReplyImageDisabled": "Image attachments are not available in this release.",
|
|
286
299
|
"error.codexIpcNotConnected": "Codex desktop is not connected right now.",
|
|
287
300
|
"error.approvalNotFound": "This approval is no longer available.",
|
|
288
301
|
"error.approvalAlreadyHandled": "This approval was already handled.",
|
|
@@ -394,6 +407,8 @@ const translations = {
|
|
|
394
407
|
"logout.action.removeDevice": "Log out and remove this device",
|
|
395
408
|
"cli.setup.warning.insecureHttpLan":
|
|
396
409
|
"Warning: insecure LAN HTTP is enabled. Session and device cookies can be exposed on the local network.",
|
|
410
|
+
"cli.setup.warning.stalePairingServer":
|
|
411
|
+
"Warning: viveworker is responding on port {port}, but it is still serving older pairing credentials. Another bridge process may still own this port. Stop the older instance, then rerun setup or start.",
|
|
397
412
|
"cli.setup.qrPairing": "Pairing QR:",
|
|
398
413
|
"cli.setup.qrCaDownload": "rootCA.pem download QR (IP):",
|
|
399
414
|
"cli.setup.prompt.continueToApp": "Press Enter to continue to the viveworker app URL.",
|
|
@@ -509,6 +524,8 @@ const translations = {
|
|
|
509
524
|
"pair.codeLabel": "ペアリングコード",
|
|
510
525
|
"pair.codePlaceholder": "ペアリングコードを入力",
|
|
511
526
|
"pair.connect": "この iPhone を接続",
|
|
527
|
+
"pair.installFromHomeScreen":
|
|
528
|
+
"この pairing link はホーム画面アプリ用に温存されています。Safari から viveworker をホーム画面に追加し、新しいアイコンを開いてそこで pairing を完了してください。",
|
|
512
529
|
"pair.helperTitle": "ホーム画面に追加",
|
|
513
530
|
"pair.helperCopy": "モバイル表示と Web Push を最適に使うには viveworker をインストールしてください。",
|
|
514
531
|
"banner.install.title": "viveworker をホーム画面に追加",
|
|
@@ -550,6 +567,12 @@ const translations = {
|
|
|
550
567
|
"reply.copy": "この完了結果を見ながら、同じ Codex スレッドに新しいメッセージを送れます。",
|
|
551
568
|
"reply.fieldLabel": "メッセージ",
|
|
552
569
|
"reply.placeholder": "続きを依頼したり、結果の修正や別案を Codex に伝えます。",
|
|
570
|
+
"reply.imageLabel": "画像",
|
|
571
|
+
"reply.imageAdd": "画像を追加",
|
|
572
|
+
"reply.imageReplace": "画像を差し替え",
|
|
573
|
+
"reply.imageRemove": "削除",
|
|
574
|
+
"reply.imageHint": "iPhone の画像を 1 枚だけ添付して、メッセージと一緒に送れます。",
|
|
575
|
+
"reply.imageAttached": "添付した画像",
|
|
553
576
|
"reply.send": "Codex に送信",
|
|
554
577
|
"reply.sendConfirm": "それでも送信",
|
|
555
578
|
"reply.sendAnother": "もう一度送る",
|
|
@@ -721,6 +744,11 @@ const translations = {
|
|
|
721
744
|
"error.completionReplyUnavailable": "この完了項目には、もう返信できません。",
|
|
722
745
|
"error.completionReplyThreadAdvanced": "このスレッドには、すでに新しいメッセージがあります。",
|
|
723
746
|
"error.completionReplyEmpty": "送信前にメッセージを入力してください。",
|
|
747
|
+
"error.completionReplyImageInvalidType": "添付できるのは画像ファイルのみです。",
|
|
748
|
+
"error.completionReplyImageTooLarge": "この画面から送るには画像が大きすぎます。",
|
|
749
|
+
"error.completionReplyImageLimit": "いまは画像を 1 枚ずつ添付してください。",
|
|
750
|
+
"error.completionReplyImageInvalidUpload": "この画像はアップロードできませんでした。",
|
|
751
|
+
"error.completionReplyImageDisabled": "このリリースでは画像添付はまだ利用できません。",
|
|
724
752
|
"error.codexIpcNotConnected": "いまは Codex desktop に接続できていません。",
|
|
725
753
|
"error.approvalNotFound": "この承認はもう利用できません。",
|
|
726
754
|
"error.approvalAlreadyHandled": "この承認はすでに処理済みです。",
|
|
@@ -832,6 +860,8 @@ const translations = {
|
|
|
832
860
|
"logout.action.removeDevice": "ログアウトしてこの端末を削除",
|
|
833
861
|
"cli.setup.warning.insecureHttpLan":
|
|
834
862
|
"警告: insecure LAN HTTP が有効です。session / device cookie がローカルネットワーク上で露出する可能性があります。",
|
|
863
|
+
"cli.setup.warning.stalePairingServer":
|
|
864
|
+
"警告: port {port} の viveworker は応答していますが、古い pairing 情報を配信したままです。別の bridge process がこの port を使っている可能性があります。古い instance を停止してから setup または start をやり直してください。",
|
|
835
865
|
"cli.setup.qrPairing": "Pairing QR:",
|
|
836
866
|
"cli.setup.qrCaDownload": "rootCA.pem download QR (IP):",
|
|
837
867
|
"cli.setup.prompt.continueToApp": "viveworker の接続先 URL を表示するには Enter を押してください。",
|
package/web/sw.js
CHANGED