Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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 @@ -107,8 +112,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
11 changes: 11 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,14 @@ export const SENTRY_DSN =
'https://966a5b01ac8d4941b81e4ebd0ab4c991@sentry.io/1882540';

export const ELECTRON_DTS = 'electron.d.ts';

// 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})$/;

// GitHub gist limits. Enforced when creating/updating a gist so we fail
// fast in main instead of round-tripping to the API. Shared with tests.
export const GIST_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB per file
export const GIST_MAX_FILE_COUNT = 300;
33 changes: 32 additions & 1 deletion src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,17 +217,48 @@ 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;
}

export enum GlobalSetting {
acceleratorsToBlock = 'acceleratorsToBlock',
channelsToShow = 'channelsToShow',
Expand Down
15 changes: 14 additions & 1 deletion src/main/constants.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import * as fs from 'node:fs';
import * as path from 'node:path';

import { app } from 'electron';

export const STATIC_DIR = path.resolve(__dirname, '../static');
// Find the root dir for static assets (eg `show-me/`, `electron-quick-start/`).
// In production, the bundled main script lives in `.webpack/main/` and webpack
// copies static assets to `.webpack/static/`.
// In tests (vitest loads the source TypeScript directly), `__dirname` is
// `src/main/` and the static folder lives at the repository root.
function resolveStaticDir(): string {
const paths = ['../static', '../../static'].map((p) =>
path.resolve(__dirname, p),
);
return paths.find(fs.existsSync) ?? paths[0];
}

export const STATIC_DIR = resolveStaticDir();

export const ELECTRON_DOWNLOAD_PATH = path.join(
app.getPath('userData'),
Expand Down
83 changes: 35 additions & 48 deletions src/main/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,20 @@ import { IpcMainInvokeEvent, app, safeStorage } from 'electron';

import { getTemplate } from './content';
import { ipcMainManager } from './ipc';
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 @@ -16,21 +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;

const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB per file — GitHub's gist limit

const MAX_FILE_COUNT = 300; // GitHub's gist file limit

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 @@ -49,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 All @@ -62,7 +63,8 @@ function areValidGistFiles(

const entries = Object.entries(files as Record<string, unknown>);

if (entries.length === 0 || entries.length > MAX_FILE_COUNT) return false;
if (entries.length === 0 || entries.length > GIST_MAX_FILE_COUNT)
return false;

for (const [key, value] of entries) {
// null entries are used to delete files during update
Expand All @@ -75,7 +77,7 @@ function areValidGistFiles(
if (filename.length === 0) return false;
if (filename !== key) return false;
if (typeof content !== 'string') return false;
if (content.length > MAX_FILE_SIZE) return false;
if (content.length > GIST_MAX_FILE_SIZE) return false;
}

return true;
Expand Down Expand Up @@ -125,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 @@ -163,28 +159,22 @@ 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 };

Expand All @@ -203,12 +193,6 @@ async function handleTokenCheckAuth(
}
}

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

async function handleGistCreate(
_event: IpcMainInvokeEvent,
params: unknown,
Expand Down Expand Up @@ -256,14 +240,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 @@ -276,12 +262,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 @@ -303,7 +288,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 @@ -316,15 +301,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 Expand Up @@ -437,6 +421,7 @@ export function setupGitHub() {
// Exported for testing
export const testing = {
fetchExample,
getCredentialsPath,
handleGistCreate,
handleGistDelete,
handleGistListCommits,
Expand All @@ -445,4 +430,6 @@ export const testing = {
handleTokenCheckAuth,
handleTokenSignIn,
handleTokenSignOut,
loadToken,
saveToken,
};
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 @@ -110,10 +112,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
12 changes: 11 additions & 1 deletion src/renderer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,17 @@ 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 }) => {
this.state.gitHubLogin = login;
})
.finally(() => {
window.ElectronFiddle.sendReady();
});

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