• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

electron / fiddle / 25565301636

08 May 2026 03:52PM UTC coverage: 88.519% (-0.1%) from 88.623%
25565301636

Pull #1940

github

web-flow
Merge aa16c07bf into 34c21579a
Pull Request #1940: feat: add `GITHUB_FETCH_EXAMPLE` to main

567 of 627 branches covered (90.43%)

Branch coverage included in aggregate %.

25 of 32 new or added lines in 4 files covered. (78.13%)

3558 of 4033 relevant lines covered (88.22%)

46.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.61
/src/main/github.ts
1
import * as fs from 'node:fs';
2
import { join as pathJoin } from 'node:path';
1✔
3

4
import { Octokit, RestEndpointMethodTypes } from '@octokit/rest';
5
import { IpcMainInvokeEvent, app, safeStorage } from 'electron';
6

7
import { getTemplate } from './content';
8
import { ipcMainManager } from './ipc';
9
import { EditorValues, GistRevision } from '../interfaces';
10
import { IpcEvents } from '../ipc-events';
11
import { isSupportedFile } from '../utils/editor-utils';
12

13
// --- Input validation ---
14

15
const ELECTRON_ORG = 'electron';
2✔
16

17
const ELECTRON_REPO = 'electron';
2✔
18

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

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

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

26
const MAX_DESCRIPTION_LENGTH = 256;
2✔
27

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

30
const MAX_FILE_COUNT = 300; // GitHub's gist file limit
2✔
31

32
function isValidToken(token: unknown): token is string {
33
  return typeof token === 'string' && TOKEN_PATTERN.test(token);
41✔
34
}
35

36
function isValidGistId(gistId: unknown): gistId is string {
37
  return typeof gistId === 'string' && GIST_ID_PATTERN.test(gistId);
51✔
38
}
39

40
function isValidSha(sha: unknown): sha is string {
41
  return typeof sha === 'string' && SHA_PATTERN.test(sha);
7✔
42
}
43

44
function isValidDescription(description: unknown): description is string {
45
  return (
14✔
46
    typeof description === 'string' &&
47
    description.length > 0 &&
48
    description.length <= MAX_DESCRIPTION_LENGTH
49
  );
50
}
51

52
interface GistFile {
53
  filename: string;
54
  content: string;
55
}
56

57
function areValidGistFiles(
58
  files: unknown,
59
): files is Record<string, GistFile | null> {
60
  if (typeof files !== 'object' || files === null || Array.isArray(files))
15✔
61
    return false;
1✔
62

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

65
  if (entries.length === 0 || entries.length > MAX_FILE_COUNT) return false;
14✔
66

67
  for (const [key, value] of entries) {
11✔
68
    // null entries are used to delete files during update
69
    if (value === null) continue;
12✔
70

71
    if (typeof value !== 'object') return false;
11✔
72

73
    const { filename, content } = value as Record<string, unknown>;
11✔
74
    if (typeof filename !== 'string') return false;
11✔
75
    if (filename.length === 0) return false;
11✔
76
    if (filename !== key) return false;
10✔
77
    if (typeof content !== 'string') return false;
8✔
78
    if (content.length > MAX_FILE_SIZE) return false;
6✔
79
  }
80

81
  return true;
5✔
82
}
83

84
// --- Token storage ---
85

86
function getCredentialsPath(): string {
87
  const CREDENTIALS_FILE = '.github-credentials';
81✔
88
  return pathJoin(app.getPath('userData'), CREDENTIALS_FILE);
81✔
89
}
90

91
function saveToken(token: string): void {
92
  const encrypted = safeStorage.encryptString(token);
26✔
93
  fs.writeFileSync(getCredentialsPath(), encrypted, { mode: 0o600 });
26✔
94
}
95

96
function loadToken(): string | null {
97
  const credPath = getCredentialsPath();
5✔
98
  try {
5✔
99
    const encrypted = fs.readFileSync(credPath);
5✔
100
    return safeStorage.decryptString(encrypted);
5✔
101
  } catch {
102
    return null;
1✔
103
  }
104
}
105

106
function deleteToken(): void {
107
  const credPath = getCredentialsPath();
50✔
108
  if (fs.existsSync(credPath)) fs.unlinkSync(credPath);
50✔
109
}
110

111
// --- Octokit management ---
112

113
let octokit_: Octokit | null = null;
2✔
114

115
function getAuthenticatedOctokit(): Octokit {
116
  if (!octokit_) throw new Error('Not authenticated. Please sign in first.');
6✔
117
  return octokit_;
5✔
118
}
119

120
function getOctokit(): Octokit {
121
  // Returns an authenticated instance if available, otherwise unauthenticated.
122
  // Unauthenticated requests have lower rate limits but work for public gists.
123
  return octokit_ || new Octokit();
14✔
124
}
125

126
// --- IPC handlers ---
127

128
interface SignInResult {
129
  success: boolean;
130
  login?: string;
131
  error?: string;
132
}
133

134
async function handleTokenSignIn(
135
  _event: IpcMainInvokeEvent,
136
  token: unknown,
137
): Promise<SignInResult> {
138
  if (!isValidToken(token))
41✔
139
    return { success: false, error: 'Invalid token format.' };
12✔
140

141
  if (!safeStorage.isEncryptionAvailable()) {
29✔
142
    return {
1✔
143
      success: false,
144
      error:
145
        'Encryption is not available on this system. Cannot securely store token.',
146
    };
147
  }
148

149
  try {
28✔
150
    const testOctokit = new Octokit({ auth: token });
28✔
151
    const response = await testOctokit.users.getAuthenticated();
28✔
152

153
    const scopes = response.headers['x-oauth-scopes']?.split(', ') || [];
27✔
154
    if (!scopes.includes('gist'))
41✔
155
      return {
1✔
156
        success: false,
157
        error:
158
          'Token is missing the "gist" scope. Please generate a new token with gist permissions.',
159
      };
160

161
    saveToken(token);
26✔
162
    octokit_ = testOctokit;
26✔
163

164
    return { success: true, login: response.data.login };
26✔
165
  } catch (error: any) {
166
    return {
1✔
167
      success: false,
168
      error: 'Invalid GitHub token. Please check your token and try again.',
169
    };
170
  }
171
}
172

173
async function handleTokenSignOut(
174
  _event: IpcMainInvokeEvent,
175
): Promise<{ success: boolean }> {
176
  deleteToken();
49✔
177
  octokit_ = null;
49✔
178
  return { success: true };
49✔
179
}
180

181
interface CheckAuthResult {
182
  login: string | null;
183
}
184

185
async function handleTokenCheckAuth(
186
  _event: IpcMainInvokeEvent,
187
): Promise<CheckAuthResult> {
188
  const token = loadToken();
5✔
189
  if (!token) return { login: null };
5✔
190

191
  try {
3✔
192
    octokit_ = new Octokit({ auth: token });
3✔
193
    const response = await octokit_.users.getAuthenticated();
3✔
194
    return { login: response.data.login };
1✔
195
  } catch (error: any) {
196
    octokit_ = null;
2✔
197

198
    if (error?.status === 401 || error?.status === 403) {
2✔
199
      deleteToken();
1✔
200
    }
201

202
    return { login: null };
2✔
203
  }
204
}
205

206
interface GistWriteResult {
207
  id: string;
208
  url: string;
209
  revision?: string;
210
}
211

212
async function handleGistCreate(
213
  _event: IpcMainInvokeEvent,
214
  params: unknown,
215
): Promise<GistWriteResult> {
216
  if (typeof params !== 'object' || params === null)
16✔
217
    throw new Error('Invalid parameters.');
2✔
218

219
  const { description, files, isPublic } = params as Record<string, unknown>;
14✔
220

221
  if (!isValidDescription(description))
14✔
222
    throw new Error(
4✔
223
      `Invalid description. Must be 1-${MAX_DESCRIPTION_LENGTH} characters.`,
224
    );
225
  if (!areValidGistFiles(files)) throw new Error('Invalid files payload.');
10✔
226
  if (typeof isPublic !== 'boolean')
4✔
227
    throw new Error('isPublic must be a boolean.');
4✔
228

229
  const octo = getAuthenticatedOctokit();
3✔
230
  const gist = await octo.gists.create({
3✔
231
    public: isPublic,
232
    description,
233
    files: files as any,
234
  });
235

236
  return {
2✔
237
    id: gist.data.id!,
238
    url: gist.data.html_url ?? '',
239
    revision: gist.data.history?.[0]?.version,
240
  };
241
}
242

243
async function handleGistUpdate(
244
  _event: IpcMainInvokeEvent,
245
  params: unknown,
246
): Promise<GistWriteResult> {
247
  if (typeof params !== 'object' || params === null)
14✔
248
    throw new Error('Invalid parameters.');
1✔
249

250
  const { gistId, files } = params as Record<string, unknown>;
13✔
251

252
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
13✔
253
  if (!areValidGistFiles(files)) throw new Error('Invalid files payload.');
5✔
254

255
  const octo = getAuthenticatedOctokit();
1✔
256

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

264
  const gist = await octo.gists.update({
1✔
265
    gist_id: gistId,
266
    files: updateFiles as any,
267
  });
268

269
  return {
1✔
270
    id: gist.data.id!,
271
    url: gist.data.html_url ?? '',
272
    revision: gist.data.history?.[0]?.version,
273
  };
274
}
275

276
async function handleGistDelete(
277
  _event: IpcMainInvokeEvent,
278
  gistId: unknown,
279
): Promise<{ success: boolean }> {
280
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
10✔
281

282
  const octo = getAuthenticatedOctokit();
2✔
283
  await octo.gists.delete({ gist_id: gistId });
2✔
284
  return { success: true };
2✔
285
}
286

287
interface GistLoadResult {
288
  files: Record<
289
    string,
290
    { filename: string; content: string; truncated: boolean; rawUrl?: string }
291
  >;
292
  id: string;
293
  revision?: string;
294
}
295

296
async function handleGistLoad(
297
  _event: IpcMainInvokeEvent,
298
  params: unknown,
299
): Promise<GistLoadResult> {
300
  if (typeof params !== 'object' || params === null)
20✔
301
    throw new Error('Invalid parameters.');
1✔
302

303
  const { gistId, revision } = params as Record<string, unknown>;
19✔
304

305
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
19✔
306
  if (revision !== undefined && !isValidSha(revision))
11✔
307
    throw new Error('Invalid revision SHA.');
5✔
308

309
  const octo = getOctokit();
6✔
310
  const gist = revision
6✔
311
    ? await octo.gists.getRevision({ gist_id: gistId, sha: revision })
312
    : await octo.gists.get({ gist_id: gistId });
313

314
  const files: GistLoadResult['files'] = {};
4✔
315
  for (const [id, data] of Object.entries(gist.data.files ?? {})) {
4✔
316
    if (!data) continue;
6✔
317

318
    // When GitHub truncates a large file, data.content is incomplete.
319
    // Fetch the full content from raw_url instead.
320
    let content = data.content ?? '';
6✔
321
    if (data.truncated && data.raw_url) {
6✔
322
      const response = await fetch(data.raw_url);
1✔
323
      if (response.ok) {
1✔
324
        content = await response.text();
1✔
325
      }
326
    }
327

328
    files[id] = {
6✔
329
      filename: data.filename ?? id,
330
      content,
331
      truncated: false,
332
      rawUrl: data.raw_url ?? undefined,
333
    };
334
  }
335

336
  return {
6✔
337
    files,
338
    id: gist.data.id!,
339
    revision: gist.data.history?.[0]?.version,
340
  };
341
}
342

343
async function handleGistListCommits(
344
  _event: IpcMainInvokeEvent,
345
  gistId: unknown,
346
): Promise<GistRevision[]> {
347
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
9✔
348

349
  const octo = getOctokit();
1✔
350
  const { data: revisions } = await octo.gists.listCommits({
1✔
351
    gist_id: gistId,
352
  });
353

354
  const oldestRevision = revisions[revisions.length - 1];
1✔
355
  const nonEmptyRevisions = revisions.filter(
1✔
356
    (r) =>
357
      r === oldestRevision ||
2✔
358
      (r.change_status.additions ?? 0) > 0 ||
359
      (r.change_status.deletions ?? 0) > 0,
360
  );
361

362
  return nonEmptyRevisions.reverse().map((r, i) => ({
2✔
363
    sha: r.version,
364
    date: r.committed_at,
365
    changes: {
366
      total: r.change_status.total ?? 0,
367
      additions: r.change_status.additions ?? 0,
368
      deletions: r.change_status.deletions ?? 0,
369
    },
370
    title: i === 0 ? 'Created' : `Revision ${i}`,
371
  }));
372
}
373

374
async function handleFetchExample(
375
  _event: IpcMainInvokeEvent,
376
  params: unknown,
377
): Promise<EditorValues> {
NEW
378
  if (typeof params !== 'object' || params === null)
×
NEW
379
    throw new Error('Invalid parameters.');
×
NEW
380
  const { ref, path } = params as Record<string, unknown>;
×
NEW
381
  if (typeof ref !== 'string') throw new Error('Invalid ref.');
×
NEW
382
  if (typeof path !== 'string') throw new Error('Invalid path.');
×
NEW
383
  return fetchExample(ref, path);
×
384
}
385

386
async function fetchExample(ref: string, path: string): Promise<EditorValues> {
387
  if (!ref) throw new Error('Invalid ref.');
9✔
388
  if (!path) throw new Error('Invalid path.');
8✔
389

390
  // `repos.getContent` returns a union; the directory variant is the array form.
391
  type RepoContentEntry = Extract<
392
    RestEndpointMethodTypes['repos']['getContent']['response']['data'],
393
    readonly unknown[]
394
  >[number];
395

396
  const owner = ELECTRON_ORG;
7✔
397
  const repo = ELECTRON_REPO;
7✔
398
  const octo = getOctokit();
7✔
399

400
  // Fetch the example folder listing.
401
  const folder = await octo.repos.getContent({ owner, path, ref, repo });
7✔
402
  if (!Array.isArray(folder.data))
6✔
403
    throw new Error(`${owner}:${repo}/${path}:${ref} is not a valid example`);
6✔
404
  const files = (folder.data as RepoContentEntry[]).filter(
5✔
405
    (file) =>
406
      typeof file.download_url === 'string' &&
9✔
407
      typeof file.name === 'string' &&
408
      isSupportedFile(file.name),
409
  );
410

411
  // Get the base template for this version: 'v42.0.0' -> '42.0.0'.
412
  const version = ref.replace(/^v/, '');
5✔
413
  const values: EditorValues = { ...(await getTemplate(version)) };
5✔
414

415
  // Download each supported file and overlay onto the template.
416
  await Promise.all(
5✔
417
    files.map(async (file) => {
418
      const resp = await fetch(file.download_url as string);
6✔
419
      if (!resp.ok)
6✔
420
        throw new Error(`Failed to download ${file.name}: ${resp.status}`);
1✔
421
      values[file.name as keyof EditorValues] = await resp.text();
5✔
422
    }),
423
  );
424

425
  return values;
4✔
426
}
427

428
// --- Setup ---
429

430
export function setupGitHub() {
431
  ipcMainManager.handle(IpcEvents.GITHUB_FETCH_EXAMPLE, handleFetchExample);
3✔
432
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_CREATE, handleGistCreate);
3✔
433
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_DELETE, handleGistDelete);
3✔
434
  ipcMainManager.handle(
3✔
435
    IpcEvents.GITHUB_GIST_LIST_COMMITS,
436
    handleGistListCommits,
437
  );
438
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_LOAD, handleGistLoad);
3✔
439
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_UPDATE, handleGistUpdate);
3✔
440
  ipcMainManager.handle(
3✔
441
    IpcEvents.GITHUB_TOKEN_CHECK_AUTH,
442
    handleTokenCheckAuth,
443
  );
444
  ipcMainManager.handle(IpcEvents.GITHUB_TOKEN_SIGN_IN, handleTokenSignIn);
3✔
445
  ipcMainManager.handle(IpcEvents.GITHUB_TOKEN_SIGN_OUT, handleTokenSignOut);
3✔
446
}
447

448
// Exported for testing
449
export const testing = {
2✔
450
  fetchExample,
451
  handleGistCreate,
452
  handleGistDelete,
453
  handleGistListCommits,
454
  handleGistLoad,
455
  handleGistUpdate,
456
  handleTokenCheckAuth,
457
  handleTokenSignIn,
458
  handleTokenSignOut,
459
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc