Skip to content

Commit

Permalink
Refactor to support multiple accounts (#187)
Browse files Browse the repository at this point in the history
* Add basic structure for multiple accounts

* Add octokit

* Move note fetching from gitnews to octokit in main process

* Remove gitnews as dependency

* Temporarily disable marking as read

* Change note fetching to use actual account info

* Add comment avatar to each note

* Add subject data to notes

* Make both comment and subject required but provide comment fallback

This matches the behavior of gitnews.

* Add gitnewsAccountId to each note

* Add mark as read functionality using Octokit

* Fix markNoficationRead for default account

* Add proxy support for github requests

* Add input form for account proxy

* Fix baseUrl for Github Enterprise Server

* Add support for socks proxies

* Log github fetch errors to main logger

* Add account edit page

* Remove editing for main token

* Re-fetch when accounts change

* Add migration from old token to new account

* Improve logging about notifications retrieved
  • Loading branch information
sirbrillig committed Sep 9, 2024
1 parent 4ed1885 commit 34f2d48
Show file tree
Hide file tree
Showing 22 changed files with 858 additions and 334 deletions.
16 changes: 12 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@
"package": "electron-forge package",
"make": "electron-forge make"
},
"keywords": ["menu", "github", "notifications"],
"keywords": [
"menu",
"github",
"notifications"
],
"author": "Payton Swick <[email protected]>",
"license": "MIT",
"dependencies": {
"@octokit/rest": "^21.0.2",
"@types/semver": "^7.5.8",
"date-fns": "^3.6.0",
"debug": "^4.3.7",
Expand All @@ -29,7 +34,7 @@
"electron-squirrel-startup": "^1.0.1",
"electron-store": "^8.2.0",
"electron-unhandled": "^5.0.0",
"gitnews": "^3.1.3",
"fetch-socks": "^1.3.0",
"gridicons": "^3.4.2",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
Expand All @@ -41,7 +46,8 @@
"redux-logger": "^3.0.6",
"redux-persist": "^6.0.0",
"semver": "^7.6.3",
"source-map-support": "^0.5.19"
"source-map-support": "^0.5.19",
"undici": "^6.19.8"
},
"devDependencies": {
"@babel/plugin-proposal-class-properties": "^7.18.6",
Expand Down Expand Up @@ -86,6 +92,8 @@
}
},
"lint-staged": {
"*.{js,ts,tsx}": ["prettier --write"]
"*.{js,ts,tsx}": [
"prettier --write"
]
}
}
58 changes: 21 additions & 37 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,19 @@ import {
import { menubar } from 'menubar';
import isDev from 'electron-is-dev';
import electronDebug from 'electron-debug';
import {
setToken,
getToken,
isLoggingEnabled,
toggleLogging,
} from './lib/main-store';
import { getToken, toggleLogging } from './lib/main-store';
import { getIconForState } from './lib/icon-path';
import { version } from '../../package.json';
import unhandled from 'electron-unhandled';
import debugFactory from 'debug';
import log from 'electron-log';
import AutoLaunch from 'easy-auto-launch';
import dotEnv from 'dotenv';
import {
fetchNotificationsForAccount,
markNotficationAsRead,
} from './lib/github-interface';
import { logMessage } from './lib/logging';
import type { AccountInfo, Note } from '../shared-types';

// These are provided by electron forge
declare const MAIN_WINDOW_WEBPACK_ENTRY: string;
Expand All @@ -41,27 +41,6 @@ electronDebug();

let lastIconState = 'loading';

// Only use this function for logging!
function logMessage(message: string, level: 'info' | 'warn' | 'error'): void {
debug(message);
if (!isLoggingEnabled()) {
return;
}
switch (level) {
case 'info':
log.info(message);
break;
case 'warn':
log.warn(message);
break;
case 'error':
log.error(message);
break;
default:
log.error(`Unknown log level '${level}': ${message}`);
}
}

const bar = menubar({
preloadWindow: true,
index: MAIN_WINDOW_WEBPACK_ENTRY,
Expand Down Expand Up @@ -139,15 +118,6 @@ ipcMain.on('quit-app', () => {
app.quit();
});

ipcMain.on('save-token', (_event, token: unknown) => {
if (typeof token !== 'string') {
logMessage('Failed to save token: it is invalid', 'error');
return;
}
setToken(token);
logMessage('Token saved', 'info');
});

const autoLauncher = new AutoLaunch({
name: 'Gitnews',
});
Expand Down Expand Up @@ -177,6 +147,20 @@ ipcMain.handle('is-demo-mode:get', async () => {
return Boolean(process.env.GITNEWS_DEMO_MODE);
});

ipcMain.handle(
'notifications-for-account:get',
async (_event, account: AccountInfo) => {
return fetchNotificationsForAccount(account);
}
);

ipcMain.handle(
'mark-note-as-read',
async (_event, note: Note, account: AccountInfo) => {
return markNotficationAsRead(note, account);
}
);

function setIcon(type?: string) {
if (!type) {
type = lastIconState;
Expand Down
176 changes: 176 additions & 0 deletions src/main/lib/github-interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import type { AccountInfo, Note, NoteReason } from '../../shared-types';
import { Octokit } from '@octokit/rest';
import { fetch as undiciFetch, ProxyAgent } from 'undici';
import { socksDispatcher } from 'fetch-socks';
import { logMessage } from './logging';

const userAgent = 'gitnews-menubar';
const mainGithubApiUrl = 'https://api.github.com';

function getSocksVersionFromProtocol(protocol: string): 4 | 5 {
const lastCharNum = parseInt(protocol.charAt(-1), 10);
if (Number.isInteger(lastCharNum) && lastCharNum === 4) {
return 4;
}
return 5;
}

function makeProxyDispatcher(proxyUrl: string) {
if (proxyUrl.startsWith('socks')) {
// eg: `socks5://user:pass@host:port` (user and pass are optional so
// `socks5://host:port` also works)
const proxyUrlData = new URL(proxyUrl);
const socksVersion = getSocksVersionFromProtocol(proxyUrlData.protocol);
// FIXME: support user and pass
const slashParts = proxyUrl.split('/');
const hostParts = slashParts.at(-1)?.split('@').at(-1)?.split(':') ?? [];
const socksHost = hostParts[0];
const socksPort = hostParts[1];
if (socksVersion && socksHost && socksPort) {
return socksDispatcher({
type: socksVersion,
host: socksHost,
port: parseInt(socksPort, 10),
});
}
}
return new ProxyAgent(proxyUrl);
}

function makeProxyFetch(proxyUrl: string) {
return (url: string, options: any) => {
return undiciFetch(url, {
...options,
dispatcher: makeProxyDispatcher(proxyUrl),
});
};
}

function createOctokit(account: AccountInfo) {
const options = {
auth: account.apiKey,
baseUrl: getBaseUrlForServer(account),
userAgent,
};
if (account.proxyUrl) {
const proxyFetch = makeProxyFetch(account.proxyUrl);
return new Octokit({
...options,
request: {
fetch: proxyFetch,
},
});
}
return new Octokit(options);
}

function getBaseUrlForServer(account: AccountInfo): string | undefined {
if (account.serverUrl === mainGithubApiUrl) {
return undefined;
}
// GitHub Enterprise Servers use this URL structure:
// https://github.com/octokit/octokit.js/?tab=readme-ov-file#octokit-api-client
const serverUrl = account.serverUrl.replace(/\/$/, '');
return `${serverUrl}/api/v3`;
}

function getOctokitRequestPathFromUrl(
account: AccountInfo,
urlString: string
): string {
const baseUrl = getBaseUrlForServer(account) ?? mainGithubApiUrl;
return urlString.replace(baseUrl, '');
}

export async function markNotficationAsRead(
note: Note,
account: AccountInfo
): Promise<void> {
const octokit = createOctokit(account);
const path = getOctokitRequestPathFromUrl(account, note.url);
try {
await octokit.request(`PATCH ${path}`, {
thread_id: note.id,
});
} catch (error) {
logMessage(
`Failed to mark notification read for ${path} (${note.url})`,
'error'
);
return;
}
}

export async function fetchNotificationsForAccount(
account: AccountInfo
): Promise<Note[]> {
const octokit = createOctokit(account);
const notificationsResponse =
await octokit.rest.activity.listNotificationsForAuthenticatedUser({
all: false,
});

const notes: Note[] = [];

for (const notification of notificationsResponse.data) {
let commentAvatar: string;
let commentHtmlUrl: string;
const commentPath = getOctokitRequestPathFromUrl(
account,
notification.subject.latest_comment_url ?? notification.subject.url
);
try {
const comment = await octokit.request(`GET ${commentPath}`, {});
commentAvatar = comment.data.user.avatar_url;
commentHtmlUrl = comment.data.html_url;
} catch (error) {
logMessage(
`Failed to fetch comment for ${commentPath} (${notification.subject.latest_comment_url ?? notification.subject.url})`,
'error'
);
continue;
}

let noteState: string;
let noteMerged: boolean;
let subjectHtmlUrl: string;
const subjectPath = getOctokitRequestPathFromUrl(
account,
notification.subject.url
);
try {
const subject = await octokit.request(`GET ${subjectPath}`, {});
noteState = subject.data.state;
noteMerged = subject.data.merged;
subjectHtmlUrl = subject.data.html_url;
} catch (error) {
logMessage(
`Failed to fetch comment for ${subjectPath} (${notification.subject.url})`,
'error'
);
continue;
}

notes.push({
gitnewsAccountId: account.id,
id: notification.id,
url: notification.url,
title: notification.subject.title,
unread: notification.unread,
repositoryFullName: notification.repository.full_name,
commentUrl: commentHtmlUrl,
updatedAt: notification.updated_at,
repositoryName: notification.repository.name,
type: notification.subject.type,
subjectUrl: subjectHtmlUrl,
commentAvatar: commentAvatar ?? notification.repository.owner.avatar_url,
repositoryOwnerAvatar: notification.repository.owner.avatar_url,
api: {
subject: { state: noteState, merged: noteMerged },
notification: { reason: notification.reason as NoteReason },
},
});
}

return notes;
}
29 changes: 29 additions & 0 deletions src/main/lib/logging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import log from 'electron-log';
import debugFactory from 'debug';
import { isLoggingEnabled } from './main-store';

const debug = debugFactory('gitnews-menubar');

// Only use this function for logging!
export function logMessage(
message: string,
level: 'info' | 'warn' | 'error'
): void {
debug(message);
if (!isLoggingEnabled()) {
return;
}
switch (level) {
case 'info':
log.info(message);
break;
case 'warn':
log.warn(message);
break;
case 'error':
log.error(message);
break;
default:
log.error(`Unknown log level '${level}': ${message}`);
}
}
4 changes: 0 additions & 4 deletions src/main/lib/main-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ export function getToken(): string {
return store.get('gitnews-token');
}

export function setToken(token: string): void {
store.set('gitnews-token', token);
}

export function isLoggingEnabled(): boolean {
return store.get('is-logging-enabled');
}
Expand Down
7 changes: 5 additions & 2 deletions src/preload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IconType, MainBridge } from './renderer/types';
import { AccountInfo, IconType, MainBridge, Note } from './renderer/types';

const bridge: MainBridge = {
quitApp: () => ipcRenderer.send('quit-app'),
Expand All @@ -10,7 +10,6 @@ const bridge: MainBridge = {
toggleAutoLaunch: (isEnabled: boolean) =>
ipcRenderer.send('toggle-auto-launch', isEnabled),
openUrl: (url: string) => ipcRenderer.send('open-url', url),
saveToken: (token: string) => ipcRenderer.send('save-token', token),
setIcon: (nextIcon: IconType) => ipcRenderer.send('set-icon', nextIcon),
onHide: (callback: () => void) => ipcRenderer.on('hide-app', callback),
onShow: (callback: () => void) => ipcRenderer.on('show-app', callback),
Expand All @@ -19,6 +18,10 @@ const bridge: MainBridge = {
getVersion: () => ipcRenderer.invoke('version:get'),
isDemoMode: () => ipcRenderer.invoke('is-demo-mode:get'),
isAutoLaunchEnabled: () => ipcRenderer.invoke('is-auto-launch:get'),
getNotificationsForAccount: (account: AccountInfo) =>
ipcRenderer.invoke('notifications-for-account:get', account),
markNotificationRead: (note: Note, account: AccountInfo) =>
ipcRenderer.invoke('mark-note-as-read', note, account),
};

contextBridge.exposeInMainWorld('electronApi', bridge);
Loading

0 comments on commit 34f2d48

Please sign in to comment.