Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor to support multiple accounts #187

Merged
merged 22 commits into from
Sep 9, 2024
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
fd25d2d
Add basic structure for multiple accounts
sirbrillig Sep 7, 2024
573d759
Add octokit
sirbrillig Sep 7, 2024
e3a1f08
Move note fetching from gitnews to octokit in main process
sirbrillig Sep 8, 2024
b0fafbe
Remove gitnews as dependency
sirbrillig Sep 8, 2024
fea7d3f
Temporarily disable marking as read
sirbrillig Sep 8, 2024
0f954b5
Change note fetching to use actual account info
sirbrillig Sep 8, 2024
852aa71
Add comment avatar to each note
sirbrillig Sep 8, 2024
4def101
Add subject data to notes
sirbrillig Sep 8, 2024
c6ef953
Make both comment and subject required but provide comment fallback
sirbrillig Sep 8, 2024
33f301b
Add gitnewsAccountId to each note
sirbrillig Sep 8, 2024
829b6d0
Add mark as read functionality using Octokit
sirbrillig Sep 8, 2024
a3e94ae
Fix markNoficationRead for default account
sirbrillig Sep 8, 2024
6aa3321
Add proxy support for github requests
sirbrillig Sep 8, 2024
aa3371e
Add input form for account proxy
sirbrillig Sep 8, 2024
7831e42
Fix baseUrl for Github Enterprise Server
sirbrillig Sep 8, 2024
1c97924
Add support for socks proxies
sirbrillig Sep 8, 2024
c968b16
Log github fetch errors to main logger
sirbrillig Sep 8, 2024
d3f2ebb
Add account edit page
sirbrillig Sep 9, 2024
1b9a090
Remove editing for main token
sirbrillig Sep 9, 2024
9a47fac
Re-fetch when accounts change
sirbrillig Sep 9, 2024
3ba0169
Add migration from old token to new account
sirbrillig Sep 9, 2024
b4ee0ce
Improve logging about notifications retrieved
sirbrillig Sep 9, 2024
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
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
Loading