Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
470fbc2
refactor: use GITHUB_GIST_LOAD in fetchGistAndLoad()
ckerr May 8, 2026
463bd53
refactor: use GITHUB_GIST_LIST_COMMITS in getGistRevisions()
ckerr May 8, 2026
5a2d820
refactor: use GITHUB_GIST_UPDATE in handleUpdate()
ckerr May 8, 2026
29ea37d
refactor: use GITHUB_GIST_DELETE in handleDelete()
ckerr May 8, 2026
38d5806
refactor: use GITHUB_GIST_CREATE in publishGist()
ckerr May 8, 2026
f486c24
refactor: use GITHUB_TOKEN_SIGN_IN in onSubmitToken()
ckerr May 8, 2026
2d4ea54
chore: code cleanup
ckerr May 8, 2026
8f1484d
refactor: finish migrating GitHub tokens to main
ckerr May 8, 2026
fa7f5e1
refactor: prefer gistId over id for readability
ckerr May 8, 2026
ace1f14
fix: ensure auth restore completes before signalling ready
ckerr May 8, 2026
913ebcf
refactor: reduce setup duplication in tests
ckerr May 8, 2026
a156489
chore: make linter happy
ckerr May 8, 2026
9e2f014
chore: fix tsc typing error
ckerr May 8, 2026
13e2fc2
test: use more production code, fewer mocks in github.spec.ts
ckerr May 9, 2026
8e500e6
refactor: minor copyediting
ckerr May 9, 2026
56e7ea5
fixup! chore: code cleanup
ckerr May 22, 2026
813c174
chore: reduce unnecessary diffs with main
ckerr May 22, 2026
04242d5
chore: make prettier happy
ckerr May 22, 2026
9e62a93
Update src/renderer/app.tsx
ckerr May 28, 2026
76dbba0
fix: do not clear github username when offline
ckerr May 28, 2026
7a45120
Update src/main/github.ts
ckerr Jun 1, 2026
e056407
Merge branch 'main' into refactor/move-github-tokens-to-main
ckerr Jun 1, 2026
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
11 changes: 11 additions & 0 deletions src/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ import {
FiddleEvent,
FileTransformOperation,
Files,
GistCreateParams,
GistLoadParams,
GistLoadResult,
GistRevision,
GistUpdateParams,
GistWriteResult,
GitHubCheckAuthResult,
GitHubSignInResult,
IPackageManager,
InstallState,
InstallStateEvent,
Expand Down Expand Up @@ -110,8 +115,14 @@ declare global {
): Promise<void>;
fetchVersions(): Promise<Version[]>;
fetchExample(ref: string, path: string): Promise<EditorValues>;
gistCreate(params: GistCreateParams): Promise<GistWriteResult>;
gistDelete(id: string): Promise<void>;
gistListCommits(gistId: string): Promise<GistRevision[]>;
gistLoad(params: GistLoadParams): Promise<GistLoadResult>;
gistUpdate(params: GistUpdateParams): Promise<GistWriteResult>;
gitHubCheckAuth(): Promise<GitHubCheckAuthResult>;
gitHubSignIn(token: string): Promise<GitHubSignInResult>;
gitHubSignOut(): Promise<void>;
getAvailableThemes(): Promise<Array<LoadedFiddleTheme>>;
getElectronTypes(ver: RunnableVersion): Promise<string | undefined>;
getIsPackageManagerInstalled(
Expand Down
6 changes: 6 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,10 @@ export const ELECTRON_DTS = 'electron.d.ts';
export const GIST_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB per file
export const GIST_MAX_FILE_COUNT = 300;

// Matches GitHub personal access tokens (classic `ghp_` and fine-grained
// `github_pat_`). Used in both the renderer (clipboard sniff) and the main
// process (sign-in validation), so they stay in lockstep.
export const GITHUB_TOKEN_PATTERN =
/^(ghp_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})$/;

export const PREFERS_DARK_MEDIA_QUERY = '(prefers-color-scheme: dark)';
34 changes: 33 additions & 1 deletion src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,17 +220,49 @@ export interface GistRevision {
};
}

export interface GistCreateParams {
description: string;
files: Record<string, GistFile>;
isPublic: boolean;
}

export interface GistFile {
filename: string;
content: string;
}

export interface GistLoadParams {
gistId: string;
revision?: string;
}

export interface GistLoadResult {
files: Record<string, { filename: string; content: string }>;
files: Record<string, GistFile>;
revision?: string;
}

export interface GistUpdateParams {
gistId: string;
files: Record<string, GistFile>;
}

export interface GistWriteResult {
id: string;
url: string;
revision?: string;
}

export interface GitHubSignInResult {
success: boolean;
login?: string;
error?: string;
}

export interface GitHubCheckAuthResult {
login: string | null;
hasToken: boolean;
}

export enum GlobalSetting {
acceleratorsToBlock = 'acceleratorsToBlock',
channelsToShow = 'channelsToShow',
Expand Down
84 changes: 36 additions & 48 deletions src/main/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,20 @@ import { IpcMainInvokeEvent, app, safeStorage } from 'electron';

import { getTemplate } from './content';
import { ipcMainManager } from './ipc';
import { GIST_MAX_FILE_COUNT, GIST_MAX_FILE_SIZE } from '../constants';
import { EditorValues, GistLoadResult, GistRevision } from '../interfaces';
import {
GIST_MAX_FILE_COUNT,
GIST_MAX_FILE_SIZE,
GITHUB_TOKEN_PATTERN,
} from '../constants';
import {
EditorValues,
GistFile,
GistLoadResult,
GistRevision,
GistWriteResult,
GitHubCheckAuthResult,
GitHubSignInResult,
} from '../interfaces';
import { IpcEvents } from '../ipc-events';
import { isSupportedFile } from '../utils/editor-utils';

Expand All @@ -17,17 +29,14 @@ const ELECTRON_ORG = 'electron';

const ELECTRON_REPO = 'electron';

const TOKEN_PATTERN =
/^(ghp_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})$/;

const GIST_ID_PATTERN = /^[0-9a-fA-F]{32}$/;

const SHA_PATTERN = /^[0-9a-f]{40}$/;

const MAX_DESCRIPTION_LENGTH = 256;

function isValidToken(token: unknown): token is string {
return typeof token === 'string' && TOKEN_PATTERN.test(token);
return typeof token === 'string' && GITHUB_TOKEN_PATTERN.test(token);
}

function isValidGistId(gistId: unknown): gistId is string {
Expand All @@ -46,11 +55,6 @@ function isValidDescription(description: unknown): description is string {
);
}

interface GistFile {
filename: string;
content: string;
}

function areValidGistFiles(
files: unknown,
): files is Record<string, GistFile | null> {
Expand Down Expand Up @@ -123,16 +127,10 @@ function getOctokit(): Octokit {

// --- IPC handlers ---

interface SignInResult {
success: boolean;
login?: string;
error?: string;
}

async function handleTokenSignIn(
_event: IpcMainInvokeEvent,
token: unknown,
): Promise<SignInResult> {
): Promise<GitHubSignInResult> {
if (!isValidToken(token))
return { success: false, error: 'Invalid token format.' };

Expand Down Expand Up @@ -161,52 +159,42 @@ async function handleTokenSignIn(

return { success: true, login: response.data.login };
} catch (error: any) {
console.warn('GitHub token sign-in failed', error);
return {
success: false,
error: 'Invalid GitHub token. Please check your token and try again.',
};
}
}

async function handleTokenSignOut(
_event: IpcMainInvokeEvent,
): Promise<{ success: boolean }> {
async function handleTokenSignOut(_event: IpcMainInvokeEvent): Promise<void> {
deleteToken();
octokit_ = null;
return { success: true };
}

interface CheckAuthResult {
login: string | null;
}

async function handleTokenCheckAuth(
_event: IpcMainInvokeEvent,
): Promise<CheckAuthResult> {
): Promise<GitHubCheckAuthResult> {
const token = loadToken();
if (!token) return { login: null };
if (!token) return { login: null, hasToken: false };

try {
octokit_ = new Octokit({ auth: token });
const response = await octokit_.users.getAuthenticated();
return { login: response.data.login };
return { login: response.data.login, hasToken: true };
} catch (error: any) {
octokit_ = null;

if (error?.status === 401 || error?.status === 403) {
octokit_ = null;
deleteToken();
return { login: null, hasToken: false };
}

return { login: null };
// If we're offline, don't invalidate the token or octokit_.
// Keep them as-is for use when we're back online.
return { login: null, hasToken: true };
}
}

interface GistWriteResult {
id: string;
url: string;
revision?: string;
}

async function handleGistCreate(
_event: IpcMainInvokeEvent,
params: unknown,
Expand Down Expand Up @@ -254,14 +242,16 @@ async function handleGistUpdate(

// Fetch existing files to detect deletions
const { data: existing } = await octo.gists.get({ gist_id: gistId });
const updateFiles = { ...(files as Record<string, GistFile | null>) };
for (const id of Object.keys(existing.files ?? {})) {
if (!(id in updateFiles)) updateFiles[id] = null as any;
const updateFiles: Record<string, GistFile | null> = { ...files };
for (const fileId of Object.keys(existing.files ?? {})) {
if (!(fileId in updateFiles)) updateFiles[fileId] = null;
}

const gist = await octo.gists.update({
gist_id: gistId,
files: updateFiles as any,
// Octokit's generated types don't model file deletion (null), but the
// REST API requires it. Cast only at the boundary.
files: updateFiles as Record<string, GistFile>,
});

return {
Expand All @@ -274,12 +264,11 @@ async function handleGistUpdate(
async function handleGistDelete(
_event: IpcMainInvokeEvent,
gistId: unknown,
): Promise<{ success: boolean }> {
): Promise<void> {
if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');

const octo = getAuthenticatedOctokit();
await octo.gists.delete({ gist_id: gistId });
return { success: true };
}

async function handleGistLoad(
Expand All @@ -301,7 +290,7 @@ async function handleGistLoad(
: await octo.gists.get({ gist_id: gistId });

const files: GistLoadResult['files'] = {};
for (const [id, data] of Object.entries(gist.data.files ?? {})) {
for (const [fileId, data] of Object.entries(gist.data.files ?? {})) {
if (!data) continue;

// When GitHub truncates a large file, data.content is incomplete.
Expand All @@ -314,15 +303,14 @@ async function handleGistLoad(
}
}

files[id] = {
filename: data.filename ?? id,
files[fileId] = {
filename: data.filename ?? fileId,
content,
};
}

return {
files,
id: gist.data.id!,
revision: gist.data.history?.[0]?.version,
};
}
Expand Down
13 changes: 13 additions & 0 deletions src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
FiddleEvent,
FileTransformOperation,
Files,
GistCreateParams,
GistLoadParams,
GistUpdateParams,
IPackageManager,
MessageOptions,
PMOperationOptions,
Expand Down Expand Up @@ -142,10 +144,21 @@ export async function setupFiddleGlobal() {
},
fetchExample: (ref: string, path: string) =>
ipcRenderer.invoke(IpcEvents.GITHUB_FETCH_EXAMPLE, { ref, path }),
gistCreate: (params: GistCreateParams) =>
ipcRenderer.invoke(IpcEvents.GITHUB_GIST_CREATE, params),
gistDelete: (id: string) =>
ipcRenderer.invoke(IpcEvents.GITHUB_GIST_DELETE, id),
gistListCommits: (gistId: string) =>
ipcRenderer.invoke(IpcEvents.GITHUB_GIST_LIST_COMMITS, gistId),
gistLoad: (params: GistLoadParams) =>
ipcRenderer.invoke(IpcEvents.GITHUB_GIST_LOAD, params),
gistUpdate: (params: GistUpdateParams) =>
ipcRenderer.invoke(IpcEvents.GITHUB_GIST_UPDATE, params),
gitHubCheckAuth: () =>
ipcRenderer.invoke(IpcEvents.GITHUB_TOKEN_CHECK_AUTH),
gitHubSignIn: (token: string) =>
ipcRenderer.invoke(IpcEvents.GITHUB_TOKEN_SIGN_IN, token),
gitHubSignOut: () => ipcRenderer.invoke(IpcEvents.GITHUB_TOKEN_SIGN_OUT),
getElectronTypes(ver: RunnableVersion) {
return ipcRenderer.invoke(IpcEvents.GET_ELECTRON_TYPES, ver);
},
Expand Down
17 changes: 16 additions & 1 deletion src/renderer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,22 @@ export class App {
this.setupUnloadListeners();
this.setupTypeListeners();

window.ElectronFiddle.sendReady();
// Restore signed-in state from main's encrypted credential, if any.
// Wait for auth restore before signalling ready so that queued IPC
// messages (e.g. deep-linked private gist loads) use the authenticated
// Octokit instance.
window.ElectronFiddle.gitHubCheckAuth()
.then(({ login, hasToken }) => {
// Only update gitHubLogin if login succeeded or if there's no token.
// If we're offline (!login && hasToken), keep the current username.
if (login || !hasToken) {
this.state.gitHubLogin = login;
}
})
Comment thread
ckerr marked this conversation as resolved.
.catch((e) => console.warn('Failed to check GitHub auth status', e))
.finally(() => {
window.ElectronFiddle.sendReady();
});

window.ElectronFiddle.addEventListener('set-show-me-template', () => {
window.ElectronFiddle.setShowMeTemplate(this.state.templateName);
Expand Down
Loading
Loading