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.
@@ -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
- child.kill?.("SIGTERM");
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
- child.kill?.("SIGTERM");
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 = setTimeout(() => {
350
- settleAuthorizationUrl(
351
- new Error("The sign-in command did not provide a fresh login link."),
362
+ const loginUrlTimeout = createTimer(() => {
363
+ const error = new Error(
364
+ "The sign-in command did not provide a fresh login link.",
352
365
  );
353
- child.kill?.("SIGTERM");
366
+ settleAuthorizationUrl(error);
367
+ void requestCancellation(error.message).catch(() => {});
354
368
  }, LOGIN_URL_TIMEOUT_MS);
355
369
 
356
- const savePendingCredential = async () => {
357
- await readAuthContents({
358
- authPath: pendingAuthPath,
359
- fileSystem,
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
- await fileSystem.chmod(pendingAuthPath, 0o600);
362
- await fileSystem.copyFile(pendingAuthPath, authPath);
363
- await fileSystem.chmod(authPath, 0o600);
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
- return await Promise.race([
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 = setTimeout(() => {
401
- child.kill?.("SIGTERM");
402
- rejectPromise(
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
- clearTimeout(processTimeout);
411
- clearTimeout(loginUrlTimeout);
412
- if (!processCloseSettled) {
413
- child.kill?.("SIGTERM");
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
- clearTimeout(loginUrlTimeout);
725
+ clearTimer(loginUrlTimeout);
439
726
  return {
440
727
  authorizationUrl,
441
728
  completion,
442
729
  cancel() {
443
- child.kill?.("SIGTERM");
730
+ return requestCancellation();
444
731
  },
445
732
  };
446
733
  } catch (error) {
447
- clearTimeout(loginUrlTimeout);
448
- child.kill?.("SIGTERM");
734
+ clearTimer(loginUrlTimeout);
735
+ let retryBlocked = false;
449
736
  try {
450
- await completion;
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
- async function waitForOAuthCompletion() {
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
- throw new Error(
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
- throw new Error(result.error ?? "The request failed.");
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
- showError(error);
558
+ if (state.oauthLoginGeneration === loginGeneration) {
559
+ if (error.oauthRetryBlocked === true) {
560
+ blockOAuthRetry();
561
+ }
562
+ showError(error);
563
+ }
489
564
  } finally {
490
- setBusy(button, false);
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>