relmio 0.4.0 → 0.5.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.
- package/CHANGELOG.md +46 -0
- package/README.md +16 -2
- package/docs/local-endpoints.md +22 -13
- package/docs/troubleshooting.md +1 -1
- package/package.json +1 -1
- package/src/domain/local-endpoints.js +4 -1
- package/src/services/local-installer.js +772 -13
- package/src/services/oauth.js +323 -28
- package/src/ui/app.js +133 -6
- package/src/ui/index.html +3 -0
- package/src/ui/local.css +140 -2
- package/src/ui/local.html +69 -1
- package/src/ui/local.js +58 -0
- package/src/web/server.js +461 -50
package/src/services/oauth.js
CHANGED
|
@@ -10,6 +10,10 @@ const LOGIN_URL_TIMEOUT_MS = 15_000;
|
|
|
10
10
|
const LOGIN_TIMEOUT_MS = 300_000;
|
|
11
11
|
const PROCESS_TIMEOUT_MS = LOGIN_TIMEOUT_MS + 15_000;
|
|
12
12
|
const CREDENTIAL_POLL_INTERVAL_MS = 100;
|
|
13
|
+
const PROCESS_TERMINATION_GRACE_MS = 1_000;
|
|
14
|
+
const PROCESS_TERMINATION_FORCE_WAIT_MS = 1_000;
|
|
15
|
+
const TERMINATION_UNCONFIRMED_MESSAGE =
|
|
16
|
+
"ChatGPT sign-in could not be stopped safely. Close the sign-in helper, then restart Relmio.";
|
|
13
17
|
const LOGIN_URL_PREFIX = "OpenAI OAuth login URL: ";
|
|
14
18
|
const OPENAI_AUTH_ORIGIN = "https://auth.openai.com";
|
|
15
19
|
const SUPPORTED_LOOPBACK_REDIRECT_HOSTNAMES = new Set([
|
|
@@ -206,6 +210,11 @@ export async function startOAuthLogin({
|
|
|
206
210
|
spawnProcess = spawn,
|
|
207
211
|
createPendingId = randomUUID,
|
|
208
212
|
waitForCredentialPoll = wait,
|
|
213
|
+
killProcess = process.kill,
|
|
214
|
+
terminationGraceMs = PROCESS_TERMINATION_GRACE_MS,
|
|
215
|
+
terminationForceWaitMs = PROCESS_TERMINATION_FORCE_WAIT_MS,
|
|
216
|
+
createTimer = setTimeout,
|
|
217
|
+
clearTimer = clearTimeout,
|
|
209
218
|
} = {}) {
|
|
210
219
|
const npxInvocation = createNpxInvocation({ platform, env, execPath });
|
|
211
220
|
const authPath = resolveAuthPath({ env, homeDirectory });
|
|
@@ -241,6 +250,7 @@ export async function startOAuthLogin({
|
|
|
241
250
|
shell: false,
|
|
242
251
|
stdio: ["ignore", "pipe", "pipe"],
|
|
243
252
|
windowsHide: true,
|
|
253
|
+
...(platform === "win32" ? {} : { detached: true }),
|
|
244
254
|
},
|
|
245
255
|
);
|
|
246
256
|
} catch (error) {
|
|
@@ -251,6 +261,7 @@ export async function startOAuthLogin({
|
|
|
251
261
|
}
|
|
252
262
|
const loginOutput = { stdout: "", stderr: "" };
|
|
253
263
|
let loginOutputBytes = 0;
|
|
264
|
+
let cancelAttempt = () => Promise.resolve();
|
|
254
265
|
let resolveAuthorizationUrl;
|
|
255
266
|
let rejectAuthorizationUrl;
|
|
256
267
|
let authorizationUrlSettled = false;
|
|
@@ -280,7 +291,9 @@ export async function startOAuthLogin({
|
|
|
280
291
|
settleAuthorizationUrl(
|
|
281
292
|
new Error("The sign-in command returned too much output."),
|
|
282
293
|
);
|
|
283
|
-
|
|
294
|
+
void requestCancellation("The sign-in command returned too much output.").catch(
|
|
295
|
+
() => {},
|
|
296
|
+
);
|
|
284
297
|
return;
|
|
285
298
|
}
|
|
286
299
|
loginOutput[stream] += output;
|
|
@@ -291,7 +304,7 @@ export async function startOAuthLogin({
|
|
|
291
304
|
}
|
|
292
305
|
} catch (error) {
|
|
293
306
|
settleAuthorizationUrl(error);
|
|
294
|
-
|
|
307
|
+
void requestCancellation(error.message).catch(() => {});
|
|
295
308
|
}
|
|
296
309
|
};
|
|
297
310
|
|
|
@@ -346,24 +359,273 @@ export async function startOAuthLogin({
|
|
|
346
359
|
settleProcessClose(null, code);
|
|
347
360
|
});
|
|
348
361
|
|
|
349
|
-
const loginUrlTimeout =
|
|
350
|
-
|
|
351
|
-
|
|
362
|
+
const loginUrlTimeout = createTimer(() => {
|
|
363
|
+
const error = new Error(
|
|
364
|
+
"The sign-in command did not provide a fresh login link.",
|
|
352
365
|
);
|
|
353
|
-
|
|
366
|
+
settleAuthorizationUrl(error);
|
|
367
|
+
void requestCancellation(error.message).catch(() => {});
|
|
354
368
|
}, LOGIN_URL_TIMEOUT_MS);
|
|
355
369
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
370
|
+
let keepPollingForCredential = true;
|
|
371
|
+
let cancellationRequested = false;
|
|
372
|
+
let rejectCancellation;
|
|
373
|
+
const cancellationPromise = new Promise((_, rejectPromise) => {
|
|
374
|
+
rejectCancellation = rejectPromise;
|
|
375
|
+
});
|
|
376
|
+
cancellationPromise.catch(() => {});
|
|
377
|
+
|
|
378
|
+
const promotionAuthPath = `${pendingAuthPath}.ready`;
|
|
379
|
+
let credentialPromotion;
|
|
380
|
+
let promotionPhase = "idle";
|
|
381
|
+
const promotionCancellationWaitMs =
|
|
382
|
+
terminationGraceMs + terminationForceWaitMs;
|
|
383
|
+
const createRetryBlockedError = () =>
|
|
384
|
+
Object.assign(new Error(TERMINATION_UNCONFIRMED_MESSAGE), {
|
|
385
|
+
retryBlocked: true,
|
|
360
386
|
});
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
387
|
+
const assertPromotionActive = () => {
|
|
388
|
+
if (cancellationRequested) {
|
|
389
|
+
throw new Error("ChatGPT sign-in did not finish. Start a fresh login.");
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
const savePendingCredential = () => {
|
|
393
|
+
if (credentialPromotion) {
|
|
394
|
+
return credentialPromotion;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
credentialPromotion = (async () => {
|
|
398
|
+
promotionPhase = "staging";
|
|
399
|
+
try {
|
|
400
|
+
assertPromotionActive();
|
|
401
|
+
await readAuthContents({
|
|
402
|
+
authPath: pendingAuthPath,
|
|
403
|
+
fileSystem,
|
|
404
|
+
});
|
|
405
|
+
assertPromotionActive();
|
|
406
|
+
await fileSystem.chmod(pendingAuthPath, 0o600);
|
|
407
|
+
assertPromotionActive();
|
|
408
|
+
await fileSystem.copyFile(pendingAuthPath, promotionAuthPath);
|
|
409
|
+
assertPromotionActive();
|
|
410
|
+
await fileSystem.chmod(promotionAuthPath, 0o600);
|
|
411
|
+
assertPromotionActive();
|
|
412
|
+
promotionPhase = "committing";
|
|
413
|
+
await fileSystem.rename(promotionAuthPath, authPath);
|
|
414
|
+
promotionPhase = "committed";
|
|
415
|
+
await fileSystem.chmod(authPath, 0o600);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
if (cancellationRequested && promotionPhase !== "committed") {
|
|
418
|
+
promotionPhase = "cancelled";
|
|
419
|
+
}
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
})();
|
|
423
|
+
credentialPromotion.catch(() => {});
|
|
424
|
+
return credentialPromotion;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const waitForBoundedResult = (promise, milliseconds) =>
|
|
428
|
+
new Promise((resolvePromise) => {
|
|
429
|
+
let settled = false;
|
|
430
|
+
const timer = createTimer(() => {
|
|
431
|
+
if (!settled) {
|
|
432
|
+
settled = true;
|
|
433
|
+
resolvePromise(false);
|
|
434
|
+
}
|
|
435
|
+
}, milliseconds);
|
|
436
|
+
promise.then(
|
|
437
|
+
() => {
|
|
438
|
+
if (!settled) {
|
|
439
|
+
settled = true;
|
|
440
|
+
clearTimer(timer);
|
|
441
|
+
resolvePromise(true);
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
() => {
|
|
445
|
+
if (!settled) {
|
|
446
|
+
settled = true;
|
|
447
|
+
clearTimer(timer);
|
|
448
|
+
resolvePromise(true);
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const waitForDuration = (milliseconds) =>
|
|
455
|
+
new Promise((resolvePromise) => {
|
|
456
|
+
createTimer(() => resolvePromise(true), milliseconds);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const waitForTaskkill = (taskkill, milliseconds) =>
|
|
460
|
+
new Promise((resolvePromise) => {
|
|
461
|
+
let settled = false;
|
|
462
|
+
const finish = (confirmed) => {
|
|
463
|
+
if (settled) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
settled = true;
|
|
467
|
+
clearTimer(timer);
|
|
468
|
+
resolvePromise(confirmed);
|
|
469
|
+
};
|
|
470
|
+
const timer = createTimer(() => finish(false), milliseconds);
|
|
471
|
+
taskkill?.once?.("error", () => finish(false));
|
|
472
|
+
taskkill?.once?.("close", (code) => finish(code === 0));
|
|
473
|
+
if (!taskkill?.once) {
|
|
474
|
+
finish(false);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
let terminationPromise;
|
|
479
|
+
const terminateProcessTree = () => {
|
|
480
|
+
if (terminationPromise) {
|
|
481
|
+
return terminationPromise;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
terminationPromise = (async () => {
|
|
485
|
+
const hasChildPid = Number.isSafeInteger(child.pid) && child.pid > 0;
|
|
486
|
+
|
|
487
|
+
if (platform === "win32" && hasChildPid) {
|
|
488
|
+
const runTaskkill = async (force, timeout) => {
|
|
489
|
+
try {
|
|
490
|
+
const taskkill = spawnProcess(
|
|
491
|
+
"taskkill",
|
|
492
|
+
["/pid", String(child.pid), "/t", ...(force ? ["/f"] : [])],
|
|
493
|
+
{
|
|
494
|
+
shell: false,
|
|
495
|
+
stdio: "ignore",
|
|
496
|
+
windowsHide: true,
|
|
497
|
+
},
|
|
498
|
+
);
|
|
499
|
+
return await waitForTaskkill(taskkill, timeout);
|
|
500
|
+
} catch {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
if (
|
|
505
|
+
(await runTaskkill(false, terminationGraceMs)) ||
|
|
506
|
+
(await runTaskkill(true, terminationForceWaitMs))
|
|
507
|
+
) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
} else if (hasChildPid) {
|
|
511
|
+
const processGroupIsGone = () => {
|
|
512
|
+
try {
|
|
513
|
+
killProcess(-child.pid, 0);
|
|
514
|
+
return false;
|
|
515
|
+
} catch (error) {
|
|
516
|
+
return error?.code === "ESRCH";
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
try {
|
|
520
|
+
killProcess(-child.pid, "SIGTERM");
|
|
521
|
+
} catch {
|
|
522
|
+
// The group may have already exited between launch and cancellation.
|
|
523
|
+
}
|
|
524
|
+
if (
|
|
525
|
+
processGroupIsGone() ||
|
|
526
|
+
((await waitForDuration(terminationGraceMs)) && processGroupIsGone())
|
|
527
|
+
) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
killProcess(-child.pid, "SIGKILL");
|
|
532
|
+
} catch {
|
|
533
|
+
// A final process-group check below determines whether it is gone.
|
|
534
|
+
}
|
|
535
|
+
if (
|
|
536
|
+
processGroupIsGone() ||
|
|
537
|
+
((await waitForDuration(terminationForceWaitMs)) &&
|
|
538
|
+
processGroupIsGone())
|
|
539
|
+
) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
} else {
|
|
543
|
+
try {
|
|
544
|
+
child.kill?.("SIGTERM");
|
|
545
|
+
} catch {
|
|
546
|
+
// The process may have already exited before cancellation.
|
|
547
|
+
}
|
|
548
|
+
if (
|
|
549
|
+
processCloseSettled ||
|
|
550
|
+
(await waitForBoundedResult(
|
|
551
|
+
processClosePromise,
|
|
552
|
+
terminationGraceMs,
|
|
553
|
+
))
|
|
554
|
+
) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
child.kill?.("SIGKILL");
|
|
559
|
+
} catch {
|
|
560
|
+
// The direct child is only a last resort when no PID is available.
|
|
561
|
+
}
|
|
562
|
+
if (
|
|
563
|
+
processCloseSettled ||
|
|
564
|
+
(await waitForBoundedResult(
|
|
565
|
+
processClosePromise,
|
|
566
|
+
terminationForceWaitMs,
|
|
567
|
+
))
|
|
568
|
+
) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
throw createRetryBlockedError();
|
|
574
|
+
})();
|
|
575
|
+
return terminationPromise;
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
cancelAttempt = async (
|
|
579
|
+
message = "ChatGPT sign-in stopped. Start a fresh login.",
|
|
580
|
+
) => {
|
|
581
|
+
if (!cancellationRequested) {
|
|
582
|
+
cancellationRequested = true;
|
|
583
|
+
keepPollingForCredential = false;
|
|
584
|
+
rejectCancellation(new Error(message));
|
|
585
|
+
}
|
|
586
|
+
let promotionError;
|
|
587
|
+
try {
|
|
588
|
+
if (
|
|
589
|
+
credentialPromotion &&
|
|
590
|
+
!(await waitForBoundedResult(
|
|
591
|
+
credentialPromotion,
|
|
592
|
+
promotionCancellationWaitMs,
|
|
593
|
+
))
|
|
594
|
+
) {
|
|
595
|
+
promotionError = createRetryBlockedError();
|
|
596
|
+
}
|
|
597
|
+
} catch {
|
|
598
|
+
// Cancellation intentionally abandons a staged but unpromoted credential.
|
|
599
|
+
}
|
|
600
|
+
if (
|
|
601
|
+
promotionPhase === "committing" ||
|
|
602
|
+
promotionPhase === "committed"
|
|
603
|
+
) {
|
|
604
|
+
promotionError = createRetryBlockedError();
|
|
605
|
+
}
|
|
606
|
+
let terminationError;
|
|
607
|
+
try {
|
|
608
|
+
await terminateProcessTree();
|
|
609
|
+
} catch (error) {
|
|
610
|
+
terminationError = error;
|
|
611
|
+
}
|
|
612
|
+
if (promotionError) {
|
|
613
|
+
throw promotionError;
|
|
614
|
+
}
|
|
615
|
+
if (terminationError) {
|
|
616
|
+
throw terminationError;
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
let cancellationResult;
|
|
621
|
+
const requestCancellation = (message) => {
|
|
622
|
+
if (!cancellationResult) {
|
|
623
|
+
cancellationResult = cancelAttempt(message);
|
|
624
|
+
cancellationResult.catch(() => {});
|
|
625
|
+
}
|
|
626
|
+
return cancellationResult;
|
|
364
627
|
};
|
|
365
628
|
|
|
366
|
-
let keepPollingForCredential = true;
|
|
367
629
|
const pendingCredentialPromise = (async () => {
|
|
368
630
|
await authorizationUrlPromise;
|
|
369
631
|
while (keepPollingForCredential) {
|
|
@@ -384,8 +646,9 @@ export async function startOAuthLogin({
|
|
|
384
646
|
|
|
385
647
|
const completion = (async () => {
|
|
386
648
|
let processTimeout;
|
|
649
|
+
let completedSuccessfully = false;
|
|
387
650
|
try {
|
|
388
|
-
|
|
651
|
+
const result = await Promise.race([
|
|
389
652
|
pendingCredentialPromise,
|
|
390
653
|
processClosePromise.then(async (code) => {
|
|
391
654
|
if (code !== 0) {
|
|
@@ -396,27 +659,51 @@ export async function startOAuthLogin({
|
|
|
396
659
|
await savePendingCredential();
|
|
397
660
|
return { success: true };
|
|
398
661
|
}),
|
|
662
|
+
cancellationPromise,
|
|
399
663
|
new Promise((_, rejectPromise) => {
|
|
400
|
-
processTimeout =
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
new Error("The sign-in request expired. Start a fresh login."),
|
|
664
|
+
processTimeout = createTimer(() => {
|
|
665
|
+
const error = new Error(
|
|
666
|
+
"The sign-in request expired. Start a fresh login.",
|
|
404
667
|
);
|
|
668
|
+
void requestCancellation(error.message).catch(() => {});
|
|
669
|
+
rejectPromise(error);
|
|
405
670
|
}, PROCESS_TIMEOUT_MS);
|
|
406
671
|
}),
|
|
407
672
|
]);
|
|
673
|
+
completedSuccessfully = result.success === true;
|
|
674
|
+
return result;
|
|
408
675
|
} finally {
|
|
409
676
|
keepPollingForCredential = false;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
677
|
+
clearTimer(processTimeout);
|
|
678
|
+
clearTimer(loginUrlTimeout);
|
|
679
|
+
try {
|
|
680
|
+
await credentialPromotion;
|
|
681
|
+
} catch {
|
|
682
|
+
// A cancellation can abandon an attempt-local staged credential.
|
|
683
|
+
}
|
|
684
|
+
if (cancellationResult) {
|
|
685
|
+
try {
|
|
686
|
+
await cancellationResult;
|
|
687
|
+
} catch (error) {
|
|
688
|
+
if (error?.retryBlocked === true) {
|
|
689
|
+
throw error;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const committedBeforeFailure =
|
|
694
|
+
!completedSuccessfully && promotionPhase === "committed";
|
|
695
|
+
if (!completedSuccessfully || !processCloseSettled) {
|
|
696
|
+
await terminateProcessTree();
|
|
414
697
|
}
|
|
415
698
|
try {
|
|
416
699
|
await fileSystem.rm(pendingAuthPath, { force: true });
|
|
700
|
+
await fileSystem.rm(promotionAuthPath, { force: true });
|
|
417
701
|
} catch {
|
|
418
702
|
// A failed cleanup must not hide the actionable sign-in result.
|
|
419
703
|
}
|
|
704
|
+
if (committedBeforeFailure) {
|
|
705
|
+
throw createRetryBlockedError();
|
|
706
|
+
}
|
|
420
707
|
}
|
|
421
708
|
})();
|
|
422
709
|
completion.catch(() => {});
|
|
@@ -435,22 +722,30 @@ export async function startOAuthLogin({
|
|
|
435
722
|
},
|
|
436
723
|
),
|
|
437
724
|
]);
|
|
438
|
-
|
|
725
|
+
clearTimer(loginUrlTimeout);
|
|
439
726
|
return {
|
|
440
727
|
authorizationUrl,
|
|
441
728
|
completion,
|
|
442
729
|
cancel() {
|
|
443
|
-
|
|
730
|
+
return requestCancellation();
|
|
444
731
|
},
|
|
445
732
|
};
|
|
446
733
|
} catch (error) {
|
|
447
|
-
|
|
448
|
-
|
|
734
|
+
clearTimer(loginUrlTimeout);
|
|
735
|
+
let retryBlocked = false;
|
|
449
736
|
try {
|
|
450
|
-
await
|
|
737
|
+
await requestCancellation(error.message);
|
|
738
|
+
} catch {
|
|
739
|
+
retryBlocked = true;
|
|
740
|
+
}
|
|
741
|
+
try {
|
|
742
|
+
await waitForBoundedResult(completion, promotionCancellationWaitMs);
|
|
451
743
|
} catch {
|
|
452
744
|
// Preserve the more specific authorization-link error.
|
|
453
745
|
}
|
|
746
|
+
if (retryBlocked) {
|
|
747
|
+
error.retryBlocked = true;
|
|
748
|
+
}
|
|
454
749
|
throw error;
|
|
455
750
|
}
|
|
456
751
|
}
|
package/src/ui/app.js
CHANGED
|
@@ -10,6 +10,10 @@ const state = {
|
|
|
10
10
|
discovery: null,
|
|
11
11
|
networks: null,
|
|
12
12
|
installAttempted: false,
|
|
13
|
+
oauthAttemptId: null,
|
|
14
|
+
oauthRetryBlocked: false,
|
|
15
|
+
oauthLoginGeneration: 0,
|
|
16
|
+
oauthLoginWindow: null,
|
|
13
17
|
};
|
|
14
18
|
|
|
15
19
|
const element = (id) => document.getElementById(id);
|
|
@@ -245,16 +249,63 @@ function validateAuthorizationUrl(value) {
|
|
|
245
249
|
return url.toString();
|
|
246
250
|
}
|
|
247
251
|
|
|
248
|
-
|
|
252
|
+
function validateOAuthAttemptId(value) {
|
|
253
|
+
if (typeof value !== "string" || !/^[0-9a-f-]{8,128}$/iu.test(value)) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
"The wizard returned an unexpected sign-in attempt. Start again.",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function setOAuthStopControlVisible(visible) {
|
|
262
|
+
const stopButton = element("stop-login-button");
|
|
263
|
+
stopButton.hidden = !visible;
|
|
264
|
+
stopButton.disabled = !visible;
|
|
265
|
+
stopButton.setAttribute("aria-busy", "false");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function blockOAuthRetry() {
|
|
269
|
+
state.oauthRetryBlocked = true;
|
|
270
|
+
state.oauthAttemptId = null;
|
|
271
|
+
state.oauthLoginWindow?.close?.();
|
|
272
|
+
state.oauthLoginWindow = null;
|
|
273
|
+
element("login-link").hidden = true;
|
|
274
|
+
element("login-link").removeAttribute("href");
|
|
275
|
+
setOAuthStopControlVisible(false);
|
|
276
|
+
const loginButton = element("login-button");
|
|
277
|
+
setBusy(loginButton, false);
|
|
278
|
+
loginButton.disabled = true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function waitForOAuthCompletion(expectedAttemptId) {
|
|
249
282
|
for (let attempt = 0; attempt < 330; attempt += 1) {
|
|
250
283
|
const result = await api("/api/oauth/status");
|
|
284
|
+
if (result.retryBlocked === true) {
|
|
285
|
+
const error = new Error(
|
|
286
|
+
result.error ??
|
|
287
|
+
"ChatGPT sign-in could not be stopped safely. Close the sign-in helper, then restart Relmio.",
|
|
288
|
+
);
|
|
289
|
+
error.oauthRetryBlocked = true;
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
if (result.attemptId !== expectedAttemptId) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
"The ChatGPT sign-in was replaced by a newer attempt. Start again.",
|
|
295
|
+
);
|
|
296
|
+
}
|
|
251
297
|
if (result.status === "success") {
|
|
252
298
|
return;
|
|
253
299
|
}
|
|
254
300
|
if (result.status === "error") {
|
|
255
|
-
|
|
301
|
+
const error = new Error(
|
|
256
302
|
result.error ?? "ChatGPT sign-in did not finish. Start again.",
|
|
257
303
|
);
|
|
304
|
+
error.oauthRetryBlocked = result.retryBlocked === true;
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
if (result.status === "cancelled") {
|
|
308
|
+
throw new Error("ChatGPT sign-in was stopped. Start again.");
|
|
258
309
|
}
|
|
259
310
|
await delay(attempt < 40 ? 250 : 1_000);
|
|
260
311
|
}
|
|
@@ -324,7 +375,9 @@ async function api(path, { method = "GET", body } = {}) {
|
|
|
324
375
|
);
|
|
325
376
|
}
|
|
326
377
|
if (!response.ok) {
|
|
327
|
-
|
|
378
|
+
const error = new Error(result.error ?? "The request failed.");
|
|
379
|
+
error.oauthRetryBlocked = result.retryBlocked === true;
|
|
380
|
+
throw error;
|
|
328
381
|
}
|
|
329
382
|
return result;
|
|
330
383
|
}
|
|
@@ -450,13 +503,21 @@ async function discover() {
|
|
|
450
503
|
|
|
451
504
|
element("login-button").addEventListener("click", async (event) => {
|
|
452
505
|
const button = event.currentTarget;
|
|
506
|
+
if (state.oauthRetryBlocked) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
453
509
|
const loginLink = element("login-link");
|
|
510
|
+
const loginGeneration = state.oauthLoginGeneration + 1;
|
|
511
|
+
state.oauthLoginGeneration = loginGeneration;
|
|
512
|
+
state.oauthAttemptId = null;
|
|
454
513
|
const loginWindow = window.open("about:blank", "_blank");
|
|
514
|
+
state.oauthLoginWindow = loginWindow;
|
|
455
515
|
let loginWindowNavigated = false;
|
|
456
516
|
prepareOAuthPopup(loginWindow);
|
|
457
517
|
clearError();
|
|
458
518
|
loginLink.hidden = true;
|
|
459
519
|
loginLink.removeAttribute("href");
|
|
520
|
+
setOAuthStopControlVisible(false);
|
|
460
521
|
setBusy(button, true, "Waiting for browser sign-in…");
|
|
461
522
|
setMessage(
|
|
462
523
|
"Preparing a fresh ChatGPT sign-in. The existing local credential will be replaced only after sign-in succeeds.",
|
|
@@ -467,6 +528,12 @@ element("login-button").addEventListener("click", async (event) => {
|
|
|
467
528
|
body: {},
|
|
468
529
|
});
|
|
469
530
|
const authorizationUrl = validateAuthorizationUrl(result.authorizationUrl);
|
|
531
|
+
const attemptId = validateOAuthAttemptId(result.attemptId);
|
|
532
|
+
if (state.oauthLoginGeneration !== loginGeneration) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
state.oauthAttemptId = attemptId;
|
|
536
|
+
setOAuthStopControlVisible(true);
|
|
470
537
|
loginLink.href = authorizationUrl;
|
|
471
538
|
loginLink.hidden = false;
|
|
472
539
|
if (loginWindow) {
|
|
@@ -477,7 +544,10 @@ element("login-button").addEventListener("click", async (event) => {
|
|
|
477
544
|
setMessage(
|
|
478
545
|
"Complete the newly opened sign-in within five minutes. If no tab opened, use “Open fresh ChatGPT sign-in” below. If an OpenAI OAuth browser extension intercepts the callback, disable it temporarily and start again.",
|
|
479
546
|
);
|
|
480
|
-
await waitForOAuthCompletion();
|
|
547
|
+
await waitForOAuthCompletion(attemptId);
|
|
548
|
+
if (state.oauthLoginGeneration !== loginGeneration) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
481
551
|
loginLink.hidden = true;
|
|
482
552
|
loginLink.removeAttribute("href");
|
|
483
553
|
await refreshAuthStatus({ fresh: true });
|
|
@@ -485,9 +555,66 @@ element("login-button").addEventListener("click", async (event) => {
|
|
|
485
555
|
if (loginWindow && !loginWindowNavigated) {
|
|
486
556
|
loginWindow.close();
|
|
487
557
|
}
|
|
488
|
-
|
|
558
|
+
if (state.oauthLoginGeneration === loginGeneration) {
|
|
559
|
+
if (error.oauthRetryBlocked === true) {
|
|
560
|
+
blockOAuthRetry();
|
|
561
|
+
}
|
|
562
|
+
showError(error);
|
|
563
|
+
}
|
|
489
564
|
} finally {
|
|
490
|
-
|
|
565
|
+
if (state.oauthLoginGeneration === loginGeneration) {
|
|
566
|
+
state.oauthAttemptId = null;
|
|
567
|
+
state.oauthLoginWindow = null;
|
|
568
|
+
setOAuthStopControlVisible(false);
|
|
569
|
+
setBusy(button, false);
|
|
570
|
+
if (state.oauthRetryBlocked) {
|
|
571
|
+
button.disabled = true;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
element("stop-login-button").addEventListener("click", async (event) => {
|
|
578
|
+
const stopButton = event.currentTarget;
|
|
579
|
+
const attemptId = state.oauthAttemptId;
|
|
580
|
+
const loginGeneration = state.oauthLoginGeneration;
|
|
581
|
+
if (typeof attemptId !== "string") {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
clearError();
|
|
585
|
+
setBusy(stopButton, true, "Stopping sign-in…");
|
|
586
|
+
try {
|
|
587
|
+
const result = await api("/api/oauth/cancel", {
|
|
588
|
+
method: "POST",
|
|
589
|
+
body: { attemptId },
|
|
590
|
+
});
|
|
591
|
+
if (
|
|
592
|
+
state.oauthLoginGeneration !== loginGeneration ||
|
|
593
|
+
result.attemptId !== attemptId ||
|
|
594
|
+
result.status !== "cancelled"
|
|
595
|
+
) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
state.oauthLoginGeneration += 1;
|
|
599
|
+
state.oauthAttemptId = null;
|
|
600
|
+
state.oauthLoginWindow?.close?.();
|
|
601
|
+
state.oauthLoginWindow = null;
|
|
602
|
+
element("login-link").hidden = true;
|
|
603
|
+
element("login-link").removeAttribute("href");
|
|
604
|
+
setBusy(element("login-button"), false);
|
|
605
|
+
setOAuthStopControlVisible(false);
|
|
606
|
+
setMessage("ChatGPT sign-in stopped. You can start again.");
|
|
607
|
+
} catch (error) {
|
|
608
|
+
if (state.oauthLoginGeneration === loginGeneration) {
|
|
609
|
+
if (error.oauthRetryBlocked === true) {
|
|
610
|
+
state.oauthLoginGeneration += 1;
|
|
611
|
+
blockOAuthRetry();
|
|
612
|
+
}
|
|
613
|
+
showError(error);
|
|
614
|
+
if (!state.oauthRetryBlocked) {
|
|
615
|
+
setBusy(stopButton, false);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
491
618
|
}
|
|
492
619
|
});
|
|
493
620
|
|
package/src/ui/index.html
CHANGED
|
@@ -203,6 +203,9 @@
|
|
|
203
203
|
>
|
|
204
204
|
Open fresh ChatGPT sign-in
|
|
205
205
|
</a>
|
|
206
|
+
<button id="stop-login-button" class="button secondary" type="button" hidden>
|
|
207
|
+
Stop ChatGPT sign-in
|
|
208
|
+
</button>
|
|
206
209
|
<button id="signin-next" class="button primary" type="button" disabled>
|
|
207
210
|
Continue to VPS
|
|
208
211
|
</button>
|