Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/main/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,10 @@ export function registerIpc(ipcMain: IpcRegistrar, {
assertMainRenderer(event, getMainWindow);
return githubAuth.signOut();
});
ipcMain.handle(IPC.githubAuthCancel, (event) => {
assertMainRenderer(event, getMainWindow);
githubAuth.cancelDeviceFlow();
});
ipcMain.handle(IPC.githubAuthOpenUrl, (event, value: unknown) => {
assertMainRenderer(event, getMainWindow);
if (typeof value !== "string") throw new Error("URL is required.");
Expand Down
201 changes: 155 additions & 46 deletions src/main/services/GithubAuthService.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ const api: CanvasTTYApi = {
status: () => ipcRenderer.invoke(IPC.githubAuthStatus),
start: () => ipcRenderer.invoke(IPC.githubAuthStart),
signOut: () => ipcRenderer.invoke(IPC.githubAuthSignOut),
cancel: () => ipcRenderer.invoke(IPC.githubAuthCancel),
openUrl: (url: string) => ipcRenderer.invoke(IPC.githubAuthOpenUrl, url)
},
browser: {
Expand Down
140 changes: 125 additions & 15 deletions src/renderer/src/features/plugins/PluginSettingsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type {
AppSettings,
GithubAuthStatus,
GithubDeviceFlowState,
GithubDeviceFlowStart,
GithubPluginSearchResult,
InstalledPlugin,
Expand Down Expand Up @@ -181,6 +182,10 @@ export function PluginSettingsSection({
const [githubCode, setGithubCode] = useState<GithubDeviceFlowStart | null>(null);
const [githubBusy, setGithubBusy] = useState(false);
const [codeCopied, setCodeCopied] = useState(false);
const githubFlowNotice = githubFlowNoticeText(locale, githubStatus?.deviceFlowState);
// The host's flow can outlive this section (switching Settings sections remounts it and drops the code),
// so a pending flow is read from the host status, not from the locally kept code.
const githubFlowPending = !githubAuthorized && githubStatus?.deviceFlowState === "pending";
const [showcasePreviews, setShowcasePreviews] = useState<Record<string, PluginInstallPreview>>({});
const [showcaseManifests, setShowcaseManifests] = useState<Record<string, PluginManifest>>({});
const [selectedShowcase, setSelectedShowcase] = useState<string | null>(null);
Expand All @@ -192,33 +197,49 @@ export function PluginSettingsSection({
const [updatingId, setUpdatingId] = useState<string | null>(null);

useEffect(() => {
if (!githubCode) return;
if (!githubCode && !githubFlowPending) return;
let cancelled = false;
const showExpired = (): void => {
setGithubStatus((current) => ({
configured: true, authorized: false, login: null, tokenExpiresAt: null, ...current, deviceFlowState: "expired"
}));
setGithubCode(null);
};
// The main process owns the device flow; this only reads its status, so it pauses while Settings is
// closed and picks up (sign-in finished or code expired) as soon as Settings opens again.
const stop = pollWhileOpen(open, githubCode.interval * 1000, async () => {
if (Date.now() >= githubCode.expiresAt) {
if (!cancelled) {
setGithubCode(null);
setError(t(locale, "githubAuthExpired"));
const stop = pollWhileOpen(open, (githubCode?.interval ?? GITHUB_PENDING_POLL_SECONDS) * 1000, async () => {
let status: GithubAuthStatus;
try {
status = await window.canvasTTY.githubAuth.status();
} catch (reason) {
if (cancelled) return STOP_POLLING;
// The displayed code's deadline does not depend on reading the host status.
if (githubCode && Date.now() >= githubCode.expiresAt) {
showExpired();
return STOP_POLLING;
}
return STOP_POLLING;
// A transient status read failure is retried on the next tick.
throw reason;
}
// A transient status read failure is retried on the next tick.
const status = await window.canvasTTY.githubAuth.status();
if (cancelled) return STOP_POLLING;
setGithubStatus(status);
if (status.authorized) {
if (status.authorized || isGithubFlowOutcome(status.deviceFlowState)) {
setGithubStatus(status);
setGithubCode(null);
return STOP_POLLING;
}
if (githubCode && Date.now() >= githubCode.expiresAt) {
setGithubStatus({ ...status, deviceFlowState: "expired" });
setGithubCode(null);
return STOP_POLLING;
}
setGithubStatus(status);
return undefined;
}, { immediate: false });
return () => {
cancelled = true;
stop();
};
}, [githubCode, locale, open]);
}, [githubCode, githubFlowPending, open]);

const inspect = async (): Promise<void> => {
if (busy || sourceUrl.trim().length === 0) return;
Expand Down Expand Up @@ -365,10 +386,22 @@ export function PluginSettingsSection({
if (githubBusy) return;
setGithubBusy(true);
setError(null);
let flow: GithubDeviceFlowStart;
try {
flow = await window.canvasTTY.githubAuth.start();
} catch (reason) {
// The IPC error is main-process text ("Error invoking remote method …", HTTP codes): it goes to the log, and the
// GitHub section shows its own localized line (not configured, or sign-in failed).
console.warn("GitHub sign-in could not start.", reason);
const status = await window.canvasTTY.githubAuth.status().catch(() => null);
setGithubStatus(status && !status.configured ? status
: { configured: true, authorized: false, login: null, tokenExpiresAt: null, ...status, deviceFlowState: "failed" });
setGithubBusy(false);
return;
}
try {
const flow = await window.canvasTTY.githubAuth.start();
setGithubCode(flow);
setGithubStatus({ configured: true, authorized: false, login: null, tokenExpiresAt: null });
setGithubStatus({ configured: true, authorized: false, login: null, tokenExpiresAt: null, deviceFlowState: "pending" });
await openGithubAuthorization(flow, target);
} catch (reason) {
setError(errorMessage(reason, t(locale, "githubAuthNotConfigured")));
Expand All @@ -387,14 +420,33 @@ export function PluginSettingsSection({
configured: current?.configured ?? false,
authorized: false,
login: null,
tokenExpiresAt: null
tokenExpiresAt: null,
deviceFlowState: "idle"
}));
setGithubCode(null);
} finally {
setGithubBusy(false);
}
};

const runGithubCancel = async (): Promise<void> => {
if (githubBusy) return;
setGithubBusy(true);
setError(null);
try {
await window.canvasTTY.githubAuth.cancel();
// The poll may have finished first; the status says which outcome won.
setGithubStatus(await window.canvasTTY.githubAuth.status());
setGithubCode(null);
setCodeCopied(false);
} catch (reason) {
console.warn("GitHub sign-in could not be cancelled.", reason);
setError(githubSigninText(locale, "cancelFailed"));
} finally {
setGithubBusy(false);
}
};

const selectShowcaseTile = (fullName: string): void => {
if (busy) return;
setSelectedShowcase(fullName);
Expand Down Expand Up @@ -833,6 +885,12 @@ export function PluginSettingsSection({
</span>
)}
</h3>
{!githubAuthorized && !githubCode && githubFlowNotice && (
// A cancelled sign-in is the person's own choice; a denied, expired or failed one is an error.
githubStatus?.deviceFlowState === "cancelled"
? <p className="plugin-github-unavailable" role="status">{githubFlowNotice}</p>
: <p className="plugin-settings__error" role="alert">{githubFlowNotice}</p>
)}
{!githubAuthorized && !githubCode && <p className="plugin-github-optional">{t(locale, "githubAuthOptional")}</p>}
{githubStatus?.authorized ? (
<div>
Expand Down Expand Up @@ -882,6 +940,17 @@ export function PluginSettingsSection({
{t(locale, "githubAuthOpenExternal")}
</button>
</div>
<button type="button" className="plugin-github-signout" disabled={githubBusy} onClick={() => void runGithubCancel()}>
{githubSigninText(locale, "cancel")}
</button>
Comment on lines +943 to +945

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Cancel available after switching Settings sections

If a user starts sign-in, visits another Settings section, and returns, SettingsPanel.tsx:1533 unmounts and remounts this component. githubCode is consequently reset to null, even though the mount-time status read reports deviceFlowState: "pending". Because the new Cancel button is inside the githubCode branch, there is then no cancellation action for that still-running flow without starting another sign-in. Retain the active flow across remounts or render a cancellation action from the service's pending state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📌 Tracking this review finding. CodeRabbit will verify future changes against the original feedback and will only mark it fixed when the code satisfies it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fix(github): keep Cancel for a pending sign-in after Settings remounts. The section now takes a pending flow from the host status (deviceFlowState: "pending"), not from the locally kept code. After a remount it shows that the sign-in waits for approval in the browser, offers Cancel, and keeps reading the status until the flow ends. Test: tests/github-signin-errors-ui.test.mjs, "a sign-in still pending in the host after the section remounts keeps its Cancel action" (fails before, passes after).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

</div>
) : githubFlowPending ? (
// The code itself was shown before the section remounted; the host flow still runs and can be stopped here.
<div className="plugin-github-flow">
<p className="plugin-github-unavailable" role="status">{githubSigninText(locale, "pending")}</p>
<button type="button" className="plugin-github-signout" disabled={githubBusy} onClick={() => void runGithubCancel()}>
{githubSigninText(locale, "cancel")}
</button>
</div>
) : githubStatus && !githubConfigured ? (
<p className="plugin-github-unavailable" role="status">{t(locale, "githubAuthNotConfigured")}</p>
Expand Down Expand Up @@ -1156,3 +1225,44 @@ function errorMessage(error: unknown, fallback: string): string {
.replace(/[\u0000-\u001f\u007f]/g, " ")
.slice(0, 320);
}

/** Status read interval for a host flow whose code this section no longer has (after a remount). */
const GITHUB_PENDING_POLL_SECONDS = 5;

function isGithubFlowOutcome(state: GithubDeviceFlowState | undefined): boolean {
return state === "denied" || state === "expired" || state === "failed" || state === "cancelled";
}

// The sign-in outcome lines live here, in the lazily loaded Settings chunk, not in the startup string table.
const GITHUB_SIGNIN_TEXT = {
ru: {
denied: "Запрос на вход в GitHub отклонён. Можно попробовать снова.",
failed: "Не удалось завершить вход в GitHub. Можно попробовать снова.",
cancelled: "Вход в GitHub отменён.",
pending: "Вход в GitHub ждёт подтверждения в браузере. Можно отменить его или начать заново.",
cancel: "Отменить вход",
cancelFailed: "Не удалось отменить вход в GitHub. Подробности записаны в журнал."
},
en: {
denied: "The GitHub sign-in request was denied. You can try again.",
failed: "GitHub sign-in could not be completed. You can try again.",
cancelled: "GitHub sign-in was cancelled.",
pending: "A GitHub sign-in is waiting for approval in the browser. You can cancel it or start again.",
cancel: "Cancel sign-in",
cancelFailed: "GitHub sign-in could not be cancelled. Details are in the log."
}
} satisfies Record<LocaleId, Record<string, string>>;

function githubSigninText(locale: LocaleId, key: keyof typeof GITHUB_SIGNIN_TEXT.en): string {
return GITHUB_SIGNIN_TEXT[locale === "ru" ? "ru" : "en"][key];
}

function githubFlowNoticeText(locale: LocaleId, state: GithubDeviceFlowState | undefined): string | null {
switch (state) {
case "denied": return githubSigninText(locale, "denied");
case "expired": return t(locale, "githubAuthExpired");
case "failed": return githubSigninText(locale, "failed");
case "cancelled": return githubSigninText(locale, "cancelled");
default: return null;
}
}
6 changes: 6 additions & 0 deletions src/shared/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1170,8 +1170,12 @@ export interface GithubAuthStatus {
authorized: boolean;
login: string | null;
tokenExpiresAt: number | null;
/** Outcome of the most recent device sign-in; "idle" when none is pending or to report. */
deviceFlowState: GithubDeviceFlowState;
}

export type GithubDeviceFlowState = "idle" | "pending" | "denied" | "expired" | "failed" | "cancelled";

export interface GithubDeviceFlowStart {
userCode: string;
verificationUri: string;
Expand Down Expand Up @@ -2091,6 +2095,7 @@ export interface CanvasTTYApi {
githubAuth: {
status(): Promise<GithubAuthStatus>;
start(): Promise<GithubDeviceFlowStart>;
cancel(): Promise<void>;
signOut(): Promise<void>;
openUrl(url: string): Promise<void>;
};
Expand Down Expand Up @@ -2292,6 +2297,7 @@ export const IPC = {
githubAuthStatus: "github-auth:status",
githubAuthStart: "github-auth:start",
githubAuthSignOut: "github-auth:sign-out",
githubAuthCancel: "github-auth:cancel",
githubAuthOpenUrl: "github-auth:open-url",
terminalList: "terminal:list",
terminalOpenFile: "terminal:open-file",
Expand Down
18 changes: 18 additions & 0 deletions tests/github-auth-integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const settingsPath = new URL(
"../src/renderer/src/features/plugins/PluginSettingsSection.tsx",
import.meta.url
);
const ipcPath = new URL("../src/main/ipc/registerIpc.ts", import.meta.url);
const preloadPath = new URL("../src/preload/index.ts", import.meta.url);

test("GitHub sign-in supports optional build input and both browser routes", async () => {
const [config, main, releaseWorkflow, settings] = await Promise.all([
Expand All @@ -31,3 +33,19 @@ test("GitHub sign-in supports optional build input and both browser routes", asy
assert.match(settings, /else await window\.canvasTTY\.githubAuth\.openUrl\(url\)/);
assert.match(settings, /const url = flow\.verificationUri/);
});

test("a pending GitHub sign-in can be cancelled through the trusted renderer only", async () => {
const [ipc, preload, settings] = await Promise.all([
readFile(ipcPath, "utf8"),
readFile(preloadPath, "utf8"),
readFile(settingsPath, "utf8")
]);
const start = ipc.indexOf("ipcMain.handle(IPC.githubAuthCancel,");
assert.notEqual(start, -1);
const handler = ipc.slice(start, ipc.indexOf("ipcMain.", start + 1));
assert.match(handler, /assertMainRenderer\(event, getMainWindow\)/);
assert.match(handler, /githubAuth\.cancelDeviceFlow\(\)/);
assert.match(preload, /cancel: \(\) => ipcRenderer\.invoke\(IPC\.githubAuthCancel\)/);
assert.match(settings, /window\.canvasTTY\.githubAuth\.cancel\(\)/);
assert.match(settings, /githubSigninText\(locale, "cancel"\)/);
});
Loading
Loading