From 45100d5de8bf591f6d38e1768e29960ee8234228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kadir=20Yaz=C4=B1c=C4=B1?= <47540799+kadiryazici@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:24:33 +0300 Subject: [PATCH] Using factory functions for testability --- agents.md | 148 ++++++++ src/gitification/actions/index.ts | 367 +++++++++++-------- src/gitification/api/index.ts | 232 ++++++++---- src/gitification/auth/index.ts | 182 +++++---- src/gitification/contextmenu/index.ts | 3 - src/gitification/i18n/index.tsx | 4 +- src/gitification/index.ts | 32 +- src/gitification/router/index.ts | 8 +- src/gitification/state/index.ts | 33 +- src/gitification/storage/index.ts | 17 +- src/gitification/storage/types.ts | 12 +- src/gitification/tauri/index.ts | 82 +++++ src/gitification/utils/github/index.ts | 123 +------ src/gitification/utils/notification/index.ts | 10 +- src/ui/Repository/Repository.vue | 4 +- src/views/HomeView.vue | 26 +- src/views/SettingsView.vue | 2 +- 17 files changed, 805 insertions(+), 480 deletions(-) create mode 100644 agents.md delete mode 100644 src/gitification/contextmenu/index.ts create mode 100644 src/gitification/tauri/index.ts diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..32a5bf5 --- /dev/null +++ b/agents.md @@ -0,0 +1,148 @@ +# Gitification Agent Guide + +## What This Project Is + +Gitification is a Vue 3 + TypeScript desktop menubar application for GitHub notifications. It is packaged with Tauri 2. The frontend displays and manages GitHub notification threads; the Rust layer owns desktop integration such as the tray icon, window behavior, sound playback, and native commands. + +## Repository Structure + +```text +src/ + App.vue Root application shell and route selection + main.ts Startup/bootstrap sequence + views/ Full-page views: landing, home, settings, about + ui/ Reusable Vue UI components + composables/ Vue/Tauri event and input helpers + gitification/ Application/domain layer + api/ GitHub API calls, request transport, and API types + actions/ User-facing operations and side effects + auth/ GitHub OAuth and deep-link callback handling + state/ Reactive runtime state + storage/ Persisted settings/accounts and migrations + router/ Small reactive in-app router + tauri/ Tauri platform adapter exposed as TauriLayer + utils/ GitHub URL, collection, date, notification helpers + constants.ts Shared constants and Tauri command names + lib.css Global styling and theme definitions + +src-tauri/ + src/main.rs Tauri setup, tray events, polling event, window events + src/commands.rs Rust commands invoked from the frontend + tauri.conf.json Tauri window, tray, bundle, updater, and deep-link config + resources/ Bundled native resources such as the notification sound + capabilities/ Tauri permissions/capabilities + +public/ Fonts and static assets +images/ README screenshots +README.md User-facing project overview +package.json Frontend scripts and dependencies +vite.config.ts Vite configuration +``` + +## Application Flow + +1. [`src/main.ts`](src/main.ts) bootstraps the application. +2. Tauri store data is loaded through `Gitification.storage.syncFromDisk()`. +3. If an active account exists, the router starts on `home`; otherwise it starts on `landing`. +4. [`src/App.vue`](src/App.vue) selects the current view, initializes OAuth listening, and starts polling. +5. The Rust layer emits a `poll_tick` event every 10 seconds. The frontend only fetches when the configured poll interval has elapsed. +6. `actions.fetchThreads()` requests notifications from GitHub, compares them with the previous list, and triggers sound/system notifications for new unread threads. +7. Unread state controls whether the tray icon uses its template/normal appearance. + +## State and Persistence + +`src/gitification/state/index.ts` exposes runtime state. Most durable state is proxied to `src/gitification/storage/index.ts`: + +- `state.users`: stored GitHub accounts and access tokens. +- `state.currentUser`: computed from `storage.activeUserId`. +- `state.settings`: persisted preferences. +- `state.threads`: current in-memory notification list; not persisted. +- `state.checkedThreadIds`: temporary multi-selection state. + +Storage is versioned as `AppStorageContextV2`. Changes to the storage shape require a migration path in `syncFromDisk()` and should preserve defaults for newly added settings. + +## Factory Composition + +The application is assembled in `src/gitification/index.ts`. Factories use named object options so dependencies remain explicit and type-safe: + +```text +createStorage({ getStorage }) +createState({ storage }) +createRouter({ defaultPage }) +createTauriLayer() +createApi({ nativeFetch }) +createActions({ state, storage, router, api, tauri }) +createAuth({ api, actions, router, state, tauri }) +``` + +`createStorage()` uses the real Tauri store by default. Tests should provide `getStorage: async () => fakeStore`. `createState()` must receive the storage instance it observes; it should not import the Gitification facade. + +`createActions()` owns action-local mutable state such as the active fetch controller and receives all external services as dependencies. Production code exposes the single assembled instance through `Gitification`; tests should create fresh factory instances. + +`utils` is a pure module and can be imported directly by application code and factories. It is intentionally not passed through dependency injection. + +All parameterized `createX` functions use named object options, including `createRouter({ defaultPage })`, `createState({ storage })`, `createStorage({ getStorage })`, `createApi({ nativeFetch })`, and `createCodeCallbackURL({ redirectUri })`. + +## GitHub and OAuth + +- GitHub API requests are centralized in `src/gitification/api/`. The private `sendRequest()` helper, request headers, authorization, query parameters, and network fallback for `createThreadHtmlURL()` all belong to `createApi()`. +- `src/gitification/utils/github/` contains pure URL helpers only. Do not add network calls there. +- `createThreadHtmlURL()` is called through `Gitification.api.createThreadHtmlURL()` because it may resolve unknown subject types through GitHub. +- OAuth uses the custom deep-link URI `gitification://oauth/callback`. +- The callback listener is registered by `useOauthListener()` and implemented in `src/gitification/auth/`. +- Required environment variables are `VITE_CLIENT_ID` and `VITE_CLIENT_SECRET`, based on `.env.example`. +- GitHub notification subject types do not all map to the same web URL. Keep URL construction changes in `createThreadHtmlURL()`. + +## Frontend Conventions + +- Use the existing `UI` components and `Gitification` namespace exports instead of introducing parallel state or service layers. +- Route changes use `Gitification.router.navigate(...)`; there is no Vue Router dependency. +- User operations that affect API state belong in `src/gitification/actions/`. +- Components should generally call actions rather than calling the API module directly. +- Tauri events should be wrapped with the existing `useTauriEvent()` composable so listeners are cleaned up with the component scope. +- The app disables the browser context menu globally because context menus are implemented in the UI layer. + +## Native/Tauri Boundary + +`src/gitification/tauri/index.ts` contains `createTauriLayer()`. It is the only application-layer adapter for Tauri operations such as window focus, notifications, AutoStart, URL opening, app lifecycle, sounds, and menubar icon state. Actions depend on the `TauriLayer` interface rather than raw Tauri functions or command names. + +OAuth is created through `createAuth(...)`. Deep-link registration is exposed by `TauriLayer.onOpenUrl()`, so auth does not import the Tauri deep-link plugin directly. The factory owns OAuth callback processing state and can be instantiated independently in tests. + +Frontend-to-Rust calls use the command names in `src/constants.ts`: + +- `play_notification_sound` +- `set_icon_template` +- `go_to_notification_settings` + +Keep platform-specific native behavior in `src-tauri/src/commands.rs`. Keep tray/window lifecycle behavior in `src-tauri/src/main.rs`. When adding a command, update the Rust `invoke_handler` and the frontend command constants/call site together. + +## Common Commands + +```bash +pnpm install +pnpm dev # Vite development server +pnpm typecheck # vue-tsc --noEmit +pnpm build # Typecheck and production Vite build +pnpm tauri dev # Run the desktop application +pnpm tauri build # Build native bundles/installers +``` + +The Tauri build configuration runs `pnpm dev` before development and `pnpm build` before production builds. Rust development also requires a working Rust/Tauri toolchain for the target platform. + +## Known Current Gaps + +- The Home view network-error Retry button is rendered without a click handler. +- The thread context-menu Unsubscribe item currently has a no-op action, although the underlying action/API functions exist. + +## Change Checklist + +Before changing behavior: + +1. Check whether the behavior belongs in a view, action, API helper, storage layer, or Rust command. +2. Preserve reactive state access through `Gitification.state` and persisted settings through `Gitification.storage`. +3. Pass factory dependencies through named object options; do not reintroduce hidden facade imports into factories. +4. Keep `utils` pure and import it directly; keep network/platform side effects in API, actions, or Tauri layers. +5. Consider account switching and logout, not only the currently active account. +6. Keep API operations guarded when `currentUser` is `null`. +7. Run `pnpm typecheck` and the relevant build/test command when dependencies are available. +8. For Tauri changes, verify both frontend invocation and Rust command registration. diff --git a/src/gitification/actions/index.ts b/src/gitification/actions/index.ts index 520ed83..f67e0ea 100644 --- a/src/gitification/actions/index.ts +++ b/src/gitification/actions/index.ts @@ -1,220 +1,271 @@ import type { HTTPError } from 'ky' +import type { Types as ApiTypes, GiApi } from '../api' +import type { GiRouter } from '../router' +import type { GiState } from '../state' +import type { GitificationStorage } from '../storage' import type { StorageUser } from '../storage/types' -import { invoke } from '@tauri-apps/api/core' -import { getCurrentWindow } from '@tauri-apps/api/window' -import * as AutoStart from '@tauri-apps/plugin-autostart' - -import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification' -import { exit, relaunch } from '@tauri-apps/plugin-process' -import { open } from '@tauri-apps/plugin-shell' -import { InvokeCommand } from '../../constants' -import * as Gitification from '../index' - -let lastFetchThreadsAt = 0 - -export function requestNotificationPermission() { - return requestPermission() -} - -export function getLastFetchThreadsAt() { - return lastFetchThreadsAt -} - -export function openURL(url: string) { - open(url) +import type { GiTauriLayer } from '../tauri' +import * as utils from '../utils' + +export type CreateActionsOptions = { + state: GiState + storage: GitificationStorage + api: GiApi + router: GiRouter + tauri: GiTauriLayer } -export async function showWindow() { - const window = getCurrentWindow() - await window.show() - await window.setFocus() -} +export function createActions(deps: CreateActionsOptions) { + let lastFetchThreadsAt = 0 + let fetchController: AbortController | null = null + let installing = false -export async function markThreadAsRead(thread: Gitification.api.Types.Thread) { - if (Gitification.state.currentUser == null) { - return + function requestNotificationPermission() { + return deps.tauri.requestNotificationPermission() } - Gitification.state.checkedThreadIds.delete(thread.id) + function getLastFetchThreadsAt() { + return lastFetchThreadsAt + } - if (Gitification.state.settings.showReadNotifications) { - thread.unread = false - return + function openURL(url: string) { + void deps.tauri.openURL(url) } - else { - Gitification.state.threads = Gitification.state.threads - .filter((t) => t.id !== thread.id) + + async function showWindow() { + await deps.tauri.showWindow() } - Gitification.api.markThreadAsRead(thread.id, Gitification.state.currentUser.accessToken) -} + async function markThreadAsRead(thread: ApiTypes.Thread) { + const currentUser = deps.state.currentUser -export function selectThread(thread: Gitification.api.Types.Thread) { - Gitification.state.checkedThreadIds.add(thread.id) -} + if (currentUser == null) { + return + } -export function deselectThread(thread: Gitification.api.Types.Thread) { - Gitification.state.checkedThreadIds.delete(thread.id) -} + deps.state.checkedThreadIds.delete(thread.id) -export { AutoStart } + if (deps.state.settings.showReadNotifications) { + thread.unread = false + return + } + else { + deps.state.threads = deps.state.threads + .filter((t) => t.id !== thread.id) + } -export function clearThreadSelection() { - Gitification.state.checkedThreadIds.clear() -} + void deps.api.markThreadAsRead(thread.id, currentUser.accessToken) + } -export function toggleThreadSelection(thread: Gitification.api.Types.Thread) { - const set = Gitification.state.checkedThreadIds - if (set.has(thread.id)) { - set.delete(thread.id) + function selectThread(thread: ApiTypes.Thread) { + deps.state.checkedThreadIds.add(thread.id) } - else { - set.add(thread.id) + + function deselectThread(thread: ApiTypes.Thread) { + deps.state.checkedThreadIds.delete(thread.id) } -} -export function unsubscribeThread(thread: Gitification.api.Types.Thread) { - if (Gitification.state.currentUser == null) { - return + function clearThreadSelection() { + deps.state.checkedThreadIds.clear() } - Gitification.state.checkedThreadIds.delete(thread.id) - Gitification.state.threads = Gitification.state.threads - .filter((t) => t.id !== thread.id) + function toggleThreadSelection(thread: ApiTypes.Thread) { + const set = deps.state.checkedThreadIds + if (set.has(thread.id)) { + set.delete(thread.id) + } + else { + set.add(thread.id) + } + } - Gitification.api.markThreadAsRead(thread.id, Gitification.state.currentUser.accessToken) - Gitification.api.unsubscribeThread(thread.id, Gitification.state.currentUser.accessToken) -} + function unsubscribeThread(thread: ApiTypes.Thread) { + const currentUser = deps.state.currentUser -export function resetThreadsState() { - Gitification.state.checkedThreadIds.clear() - Gitification.state.threads = [] - Gitification.state.threadLoadStatus = 'idle' -} + if (currentUser == null) { + return + } -export function logout(id: StorageUser['user']['id']) { - const user = Gitification.state.users - .find(({ user }) => user.id === id) ?? null + deps.state.checkedThreadIds.delete(thread.id) + deps.state.threads = deps.state.threads + .filter((t) => t.id !== thread.id) - if (user == null) { - return + void deps.api.markThreadAsRead(thread.id, currentUser.accessToken) + void deps.api.unsubscribeThread(thread.id, currentUser.accessToken) } - Gitification.state.users = Gitification.state.users - .filter((item) => item.user.id !== user.user.id) + function resetThreadsState() { + deps.state.checkedThreadIds.clear() + deps.state.threads = [] + deps.state.threadLoadStatus = 'idle' + } - const nextUser = Gitification.state.users.at(0) + function logout(id: StorageUser['user']['id']) { + const user = deps.state.users + .find(({ user }) => user.id === id) ?? null - if (nextUser) { - switchToAccount(nextUser.user.id) - return - } + if (user == null) { + return + } - resetThreadsState() - Gitification.state.currentUser = null - Gitification.router.navigate('landing') -} + deps.state.users = deps.state.users + .filter((item) => item.user.id !== user.user.id) -export function switchToAccount(userId: Gitification.api.Types.SimpleUser['id']) { - if (Gitification.state.currentUser?.user.id === userId) { - return + const nextUser = deps.state.users.at(0) + + if (nextUser) { + switchToAccount(nextUser.user.id) + return + } + + resetThreadsState() + deps.state.currentUser = null + deps.router.navigate('landing') } - resetThreadsState() - Gitification.state.currentUser = Gitification.state.users - .find(({ user }) => user.id === userId) ?? null - fetchThreads(true) - Gitification.router.navigate('home') -} + function switchToAccount(userId: ApiTypes.SimpleUser['id']) { + if (deps.state.currentUser?.user.id === userId) { + return + } -export function quitApp() { - exit(0) -} + resetThreadsState() + deps.state.currentUser = deps.state.users + .find(({ user }) => user.id === userId) ?? null + void fetchThreads(true) + deps.router.navigate('home') + } -export function playNotificationSound() { - if (Gitification.state.settings.soundsEnabled) { - invoke(InvokeCommand.PlayNotificationSound) + function quitApp() { + void deps.tauri.quitApp() } -} -export async function pushThreadNotification(thread: Gitification.api.Types.Thread) { - if (import.meta.env.DEV) { - // It crashes the app on dev mode. - return + function playNotificationSound() { + if (deps.state.settings.soundsEnabled) { + void deps.tauri.playNotificationSound() + } } - if (Gitification.state.settings.showSystemNotifications) { - if (await isPermissionGranted()) { - sendNotification({ + async function pushThreadNotification(thread: ApiTypes.Thread) { + if (import.meta.env.DEV) { + // It crashes the app in dev mode. + return + } + + if (deps.state.settings.showSystemNotifications + && await deps.tauri.hasNotificationPermission()) { + deps.tauri.notify({ title: thread.repository.full_name, body: thread.subject.title, }) } } -} - -export async function fetchThreads(withLoader = false) { - if (Gitification.state.currentUser == null) { - return - } - lastFetchThreadsAt = Date.now() + async function fetchThreads(withLoader = false) { + fetchController?.abort() + fetchController = null - if (withLoader) { - clearThreadSelection() - } + const currentUser = deps.state.currentUser + if (currentUser == null) { + return + } - Gitification.state.threadLoadStatus = withLoader ? 'loading' : 'syncing' + lastFetchThreadsAt = Date.now() - const result = await Gitification.api - .getThreads({ - all: Gitification.state.settings.showReadNotifications, - accessToken: Gitification.state.currentUser.accessToken, - onlyParticipating: Gitification.state.settings.onlyParticipating, - }) - .catch((error) => error as HTTPError) + if (withLoader) { + clearThreadSelection() + } - if (result instanceof Error) { - Gitification.state.threadLoadStatus = 'failed' - return - } + deps.state.threadLoadStatus = withLoader ? 'loading' : 'syncing' - const [threads] = result + const controller = new AbortController() + fetchController = controller + const signal = controller.signal - const newThreads = Gitification.utils.array.filterNewItems( - Gitification.state.threads, - threads, - (thread) => thread.id, - ) + const result = await deps.api + .getThreads({ + all: deps.state.settings.showReadNotifications, + accessToken: currentUser.accessToken, + onlyParticipating: deps.state.settings.onlyParticipating, + signal, + }) + .catch((error) => error as HTTPError) - const newUnread = newThreads.find((thread) => thread.unread) + if (signal.aborted) { + return + } - if (newUnread) { - playNotificationSound() - pushThreadNotification(newUnread) - } + if (result instanceof Error) { + deps.state.threadLoadStatus = 'failed' + return + } - Gitification.state.threads = threads - Gitification.state.threadLoadStatus = 'idle' -} + const [threads] = result + const newThreads = utils.array.filterNewItems( + deps.state.threads, + threads, + (thread) => thread.id, + ) + const newUnread = newThreads.find((thread) => thread.unread) + + if (newUnread) { + playNotificationSound() + void pushThreadNotification(newUnread) + } -export async function setMenubarIcon(isTemplate: boolean) { - await invoke(InvokeCommand.SetIconTemplate, { isTemplate }) -} + deps.state.threads = threads + deps.state.threadLoadStatus = 'idle' + } -let installing = false -export async function updateApp() { - if (installing || Gitification.state.newRelease == null) { - return + async function setMenubarIcon(isTemplate: boolean) { + await deps.tauri.setMenubarIcon(isTemplate) } - installing = true + async function updateApp() { + if (installing || deps.state.newRelease == null) { + return + } + + installing = true - try { - await Gitification.state.newRelease.downloadAndInstall() - await relaunch() + try { + await deps.state.newRelease.downloadAndInstall() + await deps.tauri.relaunchApp() + } + catch { + installing = false + } } - catch { - installing = false + + return { + AutoStart: { + isEnabled: deps.tauri.isAutoStartEnabled, + enable: deps.tauri.enableAutoStart, + disable: deps.tauri.disableAutoStart, + }, + resetSettings() { + void deps.tauri.requestNotificationPermission() + deps.storage.resetSettings() + }, + requestNotificationPermission, + getLastFetchThreadsAt, + openURL, + showWindow, + markThreadAsRead, + selectThread, + deselectThread, + clearThreadSelection, + toggleThreadSelection, + unsubscribeThread, + resetThreadsState, + logout, + switchToAccount, + quitApp, + playNotificationSound, + pushThreadNotification, + fetchThreads, + setMenubarIcon, + updateApp, } } + +export type GiActions = ReturnType diff --git a/src/gitification/api/index.ts b/src/gitification/api/index.ts index 191d6b3..e719028 100644 --- a/src/gitification/api/index.ts +++ b/src/gitification/api/index.ts @@ -1,21 +1,171 @@ +import type { StorageUser } from '../storage/types' import type * as ApiTypes from './types' -import { fetch as tFetch } from '@tauri-apps/plugin-http' -import { Mutex } from 'async-mutex' -import * as Gitification from '../index' export type { ApiTypes as Types } -export const mutex = new Mutex() +export type CreateApiOptions = { + nativeFetch: typeof import('@tauri-apps/plugin-http').fetch +} + +type GithubApiRequestOptions = { + method: string + searchParams?: Record + headers?: HeadersInit + accessToken: string + signal?: AbortSignal +} + +export function createApi({ nativeFetch }: CreateApiOptions) { + async function sendRequest(url: string, options: GithubApiRequestOptions) { + const { method, searchParams, headers: _headers = {}, accessToken, signal } = options + const requestURL = new URL(url) + + for (const [key, value] of Object.entries(searchParams ?? {})) { + requestURL.searchParams.set(key, String(value)) + } + + requestURL.searchParams.set('t', Date.now().toString()) + + const headers = new Headers({ + 'Accept': 'application/json', + 'Content-Type': 'application/json', + ..._headers, + }) + headers.set('Authorization', `token ${accessToken}`) + + const response = await nativeFetch(requestURL.toString(), { + method, + headers, + signal, + }) -export function getUser(accessToken: string) { - const req = Gitification.utils.github.sendRequest('https://api.github.com/user', { - method: 'get', - accessToken, - }) + return [await response.json() as T, response] as const + } + + function getUser(accessToken: string) { + const req = sendRequest('https://api.github.com/user', { + method: 'get', + accessToken, + }) + + return req + .then((res) => res) + .catch(() => null) + } + + async function getAccessToken({ clientId, clientSecret, code, redirectUri }: GetAccessTokenArgs) { + const res = await nativeFetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }), + }) + + if (!res.ok) { + throw res + } + + return { ...res, data: await res.json() as ApiTypes.AccessToken } + } + + function getThreads(args: GetThreadsArgs) { + const { onlyParticipating, all, accessToken, signal } = args + const headers = new Headers() + + return sendRequest('https://api.github.com/notifications', { + accessToken, + method: 'get', + headers, + signal, + searchParams: { + participating: onlyParticipating, + all, + t: Date.now(), + }, + }) + } - return req - .then((res) => res) - .catch(() => null) + function markThreadAsRead(id: ApiTypes.Thread['id'], accessToken: string) { + return sendRequest(`https://api.github.com/notifications/threads/${id}`, { + method: 'patch', + accessToken, + }) + } + + async function unsubscribeThread(id: ApiTypes.Thread['id'], accessToken: string) { + await sendRequest(`https://api.github.com/notifications/threads/${id}/subscription`, { + method: 'put', + accessToken, + }) + + await markThreadAsRead(id, accessToken) + } + + async function createThreadHtmlURL({ thread, user }: CreateThreadUrlArgs) { + const notificationReferrerId = btoa(`018:NotificationThread${thread.id}:${user.user.id}`) + let url: null | string = null + + if (thread.subject.type === 'CheckSuite') { + url = `https://github.com/${thread.repository.full_name}/actions` + } + else if (thread.subject.type === 'RepositoryInvitation') { + url = `https://github.com/${thread.repository.full_name}/invitations` + } + else if (thread.subject.type === 'Discussion') { + url = (thread.subject.url as string).replace('api.github.com/repos/', 'https://github.com/') + } + else if (thread.subject.type === 'Release') { + url = `https://github.com/${thread.repository.full_name}/releases/` + } + else if (thread.subject.type === 'PullRequest') { + const prId = (thread.subject.url as string).split('?')[0].split('/').at(-1) as string + url = `https://github.com/${thread.repository.full_name}/pull/${prId}` + } + else if (thread.subject.type === 'Issue') { + const issueId = (thread.subject.url as string).split('?')[0].split('/').at(-1) as string + url = `https://github.com/${thread.repository.full_name}/issues/${issueId}` + } + else if (thread.subject.type === 'Commit') { + const commitId = (thread.subject.url as string).split('?')[0].split('/').at(-1) as string + url = `https://github.com/${thread.repository.full_name}/commit/${commitId}` + } + else if (thread.subject.url != null) { + try { + const [data] = await sendRequest<{ html_url?: string }>(thread.subject.url, { + method: 'GET', + accessToken: user.accessToken, + }) + url = data?.html_url ?? null + } + catch { + url = null + } + } + + if (url == null) { + return null + } + + const result = new URL(url) + result.searchParams.set('notification_referrer_id', notificationReferrerId) + return result.toString() + } + + return { + getUser, + getAccessToken, + getThreads, + markThreadAsRead, + unsubscribeThread, + createThreadHtmlURL, + } } export type GetAccessTokenArgs = { @@ -25,64 +175,16 @@ export type GetAccessTokenArgs = { redirectUri: string } -export async function getAccessToken({ clientId, clientSecret, code, redirectUri }: GetAccessTokenArgs) { - const res = await tFetch('https://github.com/login/oauth/access_token', { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: clientId, - client_secret: clientSecret, - code, - redirect_uri: redirectUri, - }), - }) - - if (!res.ok) { - throw res - } - - return { ...res, data: await res.json() as ApiTypes.AccessToken } -} - export type GetThreadsArgs = { onlyParticipating: boolean all: boolean accessToken: string - ifModifiedSince?: string -} - -export function getThreads(args: GetThreadsArgs) { - const { onlyParticipating, all, accessToken } = args - - const headers = new Headers() - - return Gitification.utils.github.sendRequest('https://api.github.com/notifications', { - accessToken, - method: 'get', - headers, - searchParams: { - participating: onlyParticipating, - all, - t: Date.now(), - }, - }) + signal?: AbortSignal } -export function markThreadAsRead(id: ApiTypes.Thread['id'], accessToken: string) { - return Gitification.utils.github.sendRequest(`https://api.github.com/notifications/threads/${id}`, { - method: 'patch', - accessToken, - }) +export type CreateThreadUrlArgs = { + thread: ApiTypes.Thread + user: StorageUser } -export async function unsubscribeThread(id: ApiTypes.Thread['id'], accessToken: string) { - await Gitification.utils.github.sendRequest(`https://api.github.com/notifications/threads/${id}/subscription`, { - method: 'put', - accessToken, - }) - - await markThreadAsRead(id, accessToken) -} +export type GiApi = ReturnType diff --git a/src/gitification/auth/index.ts b/src/gitification/auth/index.ts index 30ebc25..3c4e818 100644 --- a/src/gitification/auth/index.ts +++ b/src/gitification/auth/index.ts @@ -1,108 +1,128 @@ -import { onOpenUrl } from '@tauri-apps/plugin-deep-link' -import * as Gitification from '../index' +import type { GiActions } from '../actions' +import type { GiApi } from '../api' +import type { GiRouter } from '../router' +import type { GiState } from '../state' +import type { GiTauriLayer } from '../tauri' +import * as utils from '../utils' const REDIRECT_URI = 'gitification://oauth/callback' -let processing = false +export type CreateAuthOptions = { + api: GiApi + actions: Pick + router: GiRouter + state: GiState + tauri: GiTauriLayer +} -async function handleUrl(rawUrl: string) { - let url: URL +export function createAuth(deps: CreateAuthOptions) { + let processing = false - try { - url = new URL(rawUrl) - } - catch { - return - } + async function handleUrl(rawUrl: string) { + let url: URL - if (url.protocol !== 'gitification:' || url.hostname !== 'oauth' || url.pathname !== '/callback') { - return - } + try { + url = new URL(rawUrl) + } + catch { + return + } - const error = url.searchParams.get('error') - const code = url.searchParams.get('code') + if (url.protocol !== 'gitification:' || url.hostname !== 'oauth' || url.pathname !== '/callback') { + return + } - if (error != null) { - console.error(`GitHub OAuth failed: ${error}`) - return - } + const error = url.searchParams.get('error') + const code = url.searchParams.get('code') - if (code == null) { - console.error('Ignored GitHub OAuth callback without a code') - return - } + if (error != null) { + console.error(`GitHub OAuth failed: ${error}`) + return + } - if (processing) { - return - } + if (code == null) { + console.error('Ignored GitHub OAuth callback without a code') + return + } - processing = true + if (processing) { + return + } - try { - const { data: { access_token: accessToken } } = await Gitification.api.getAccessToken({ - clientId: import.meta.env.VITE_CLIENT_ID, - clientSecret: import.meta.env.VITE_CLIENT_SECRET, - code, - redirectUri: REDIRECT_URI, - }) + processing = true - const result = await Gitification.api.getUser(accessToken) + try { + const { data: { access_token: accessToken } } = await deps.api.getAccessToken({ + clientId: import.meta.env.VITE_CLIENT_ID, + clientSecret: import.meta.env.VITE_CLIENT_SECRET, + code, + redirectUri: REDIRECT_URI, + }) - if (result == null) { - throw new Error('Failed to fetch user data') - } + const result = await deps.api.getUser(accessToken) + + if (result == null) { + throw new Error('Failed to fetch user data') + } - const [user] = result + const [user] = result - if (user == null) { - throw new Error('GitHub did not return a user') - } + if (user == null) { + throw new Error('GitHub did not return a user') + } - const account = { user, accessToken } - const existingIndex = Gitification.state.users - .findIndex(({ user: existingUser }) => existingUser.id === user.id) + const account = { user, accessToken } + const existingIndex = deps.state.users + .findIndex(({ user: existingUser }) => existingUser.id === user.id) - if (existingIndex === -1) { - Gitification.state.users.push(account) + if (existingIndex === -1) { + deps.state.users.push(account) + } + else { + deps.state.users[existingIndex] = account + } + + deps.state.currentUser = account + deps.router.navigate('home') + await deps.actions.showWindow() } - else { - Gitification.state.users[existingIndex] = account + catch (error) { + console.error('GitHub OAuth callback failed', error) + } + finally { + processing = false } - - Gitification.state.currentUser = account - Gitification.router.navigate('home') - await Gitification.actions.showWindow() } - catch (error) { - console.error('GitHub OAuth callback failed', error) + + function getRedirectUri() { + return REDIRECT_URI } - finally { - processing = false + + function openAuthorization() { + deps.actions.openURL( + utils.github.createCodeCallbackURL({ redirectUri: REDIRECT_URI }), + ) } -} -export function getRedirectUri() { - return REDIRECT_URI -} + async function initialize() { + return deps.tauri.onOpenUrl((urls) => { + void (async () => { + if (urls == null) { + return + } + + for (const url of urls) { + await handleUrl(url) + } + })() + }) + } -export function openAuthorization() { - Gitification.actions.openURL( - Gitification.utils.github.createCodeCallbackURL(REDIRECT_URI), - ) + return { + getRedirectUri, + openAuthorization, + initialize, + } } -export async function initialize() { - const unlisten = await onOpenUrl((urls) => { - void (async () => { - if (urls == null) { - return - } - - for (const url of urls) { - await handleUrl(url) - } - })() - }) - - return unlisten -} +export type Auth = ReturnType diff --git a/src/gitification/contextmenu/index.ts b/src/gitification/contextmenu/index.ts deleted file mode 100644 index a889c3f..0000000 --- a/src/gitification/contextmenu/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function createContextMenu() { - -} diff --git a/src/gitification/i18n/index.tsx b/src/gitification/i18n/index.tsx index 5c0e700..0956809 100644 --- a/src/gitification/i18n/index.tsx +++ b/src/gitification/i18n/index.tsx @@ -1,4 +1,4 @@ -import type * as Gitification from '../index' +import type { Types as ApiTypes } from '../api' import { Fragment } from 'vue' const en = { @@ -67,7 +67,7 @@ const en = { push: 'Push', security_advisory_credit: 'Security advisory credit', your_activity: 'Your activity', - } satisfies Record, + } satisfies Record, } export default en diff --git a/src/gitification/index.ts b/src/gitification/index.ts index 953b36e..cd5aafd 100644 --- a/src/gitification/index.ts +++ b/src/gitification/index.ts @@ -1,15 +1,35 @@ +import { fetch as tFetch } from '@tauri-apps/plugin-http' +import { createActions } from './actions' +import { createApi } from './api' +import { createAuth } from './auth' import { createRouter } from './router' import { createState } from './state' import { createStorage } from './storage' import * as StorageTypes from './storage/types' +import { createTauriLayer } from './tauri' -export * as actions from './actions' -export * as api from './api' -export * as auth from './auth' export { default as i18n } from './i18n' export * as utils from './utils' -export const state = createState() -export const router = createRouter('landing') -export const storage = createStorage() +export const storage = createStorage({}) +export const state = createState({ storage }) +export const router = createRouter({ defaultPage: 'landing' }) +export const tauri = createTauriLayer() +export const api = createApi({ + nativeFetch: tFetch, +}) +export const actions = createActions({ + state, + storage, + api, + router, + tauri, +}) +export const auth = createAuth({ + api, + actions, + router, + state, + tauri, +}) export { StorageTypes } diff --git a/src/gitification/router/index.ts b/src/gitification/router/index.ts index d91da09..4c2fb10 100644 --- a/src/gitification/router/index.ts +++ b/src/gitification/router/index.ts @@ -4,7 +4,11 @@ import { shallowRef } from 'vue' export const routes = ['home', 'landing', 'settings', 'about'] as const export type RouteName = typeof routes[number] -export function createRouter(defaultPage: RouteName) { +export type CreateRouterOptions = { + defaultPage: RouteName +} + +export function createRouter({ defaultPage }: CreateRouterOptions) { const previous = shallowRef>(null) const current = shallowRef(defaultPage) @@ -29,4 +33,4 @@ export function createRouter(defaultPage: RouteName) { } } -export type Router = ReturnType +export type GiRouter = ReturnType diff --git a/src/gitification/state/index.ts b/src/gitification/state/index.ts index 8172c63..83af9ed 100644 --- a/src/gitification/state/index.ts +++ b/src/gitification/state/index.ts @@ -2,16 +2,21 @@ import type { OsType } from '@tauri-apps/plugin-os' import type { Update } from '@tauri-apps/plugin-updater' import type { Option } from '../../types' +import type { Types as ApiTypes } from '../api' +import type { GitificationStorage } from '../storage' import { useMediaQuery } from '@vueuse/core' import { computed, reactive, ref, shallowRef } from 'vue' -import * as Gitification from '../index' -export type GitificationState = ReturnType +export type GiState = ReturnType -export function createState() { - const threads = ref([] as Gitification.api.Types.Thread[]) +export type CreateStateOptions = { + storage: GitificationStorage +} + +export function createState({ storage }: CreateStateOptions) { + const threads = ref([] as ApiTypes.Thread[]) const threadLookup = computed(() => { - const lookup: Record = {} + const lookup: Record = {} for (const thread of threads.value) { lookup[thread.id] = thread } @@ -27,25 +32,23 @@ export function createState() { const osType = ref('Darwin' as OsType) const users = computed({ - get: () => Gitification.storage.value.users, - set: (value) => { - Gitification.storage.value.users = value - }, + get: () => storage.value.users, + set: (value) => storage.value.users = value, }) const settings = computed({ - get: () => Gitification.storage.value.settings, - set: (value) => void (Gitification.storage.value.settings = value), + get: () => storage.value.settings, + set: (value) => void (storage.value.settings = value), }) const currentUser = computed({ get() { return users.value - .find((value) => value.user.id === Gitification.storage.value.activeUserId) + .find((value) => value.user.id === storage.value.activeUserId) ?? null }, set(user) { - Gitification.storage.value.activeUserId = user?.user.id ?? null + storage.value.activeUserId = user?.user.id ?? null }, }) @@ -53,7 +56,7 @@ export function createState() { const theme = computed({ get() { - let preference = Gitification.storage.value.settings.colorPreference + let preference = storage.value.settings.colorPreference if (preference === 'system') { preference = prefersDark.value ? 'dark' : 'light' @@ -62,7 +65,7 @@ export function createState() { return preference }, set(value: 'light' | 'dark' | 'system') { - Gitification.storage.value.settings.colorPreference = value + storage.value.settings.colorPreference = value }, }) diff --git a/src/gitification/storage/index.ts b/src/gitification/storage/index.ts index fc594ac..555ef2d 100644 --- a/src/gitification/storage/index.ts +++ b/src/gitification/storage/index.ts @@ -1,11 +1,18 @@ import * as TauriStore from '@tauri-apps/plugin-store' import { extendRef } from '@vueuse/core' import { ref, watch } from 'vue' -import * as Gitification from '../index' import * as StorageTypes from './types' -export function createStorage() { - const storePromise = TauriStore.load('.storage.dat', { autoSave: false, defaults: {} }) +export type StorageStore = Awaited> + +export type CreateStorageOptions = { + getStorage?: () => Promise +} + +export function createStorage({ + getStorage = () => TauriStore.load('.storage.dat', { autoSave: false, defaults: {} }), +}: CreateStorageOptions = {}) { + const storePromise = getStorage() const storage = ref({ version: 2, activeUserId: null, @@ -97,8 +104,6 @@ export function createStorage() { return extendRef(storage, { syncFromDisk, resetSettings() { - Gitification.actions.requestNotificationPermission() - storage.value.settings = { onlyParticipating: false, openAtStartup: false, @@ -118,7 +123,7 @@ export function createStorage() { }) } -export type Storage = ReturnType +export type GitificationStorage = ReturnType export { StorageTypes as Types, diff --git a/src/gitification/storage/types.ts b/src/gitification/storage/types.ts index 2f39722..47b8311 100644 --- a/src/gitification/storage/types.ts +++ b/src/gitification/storage/types.ts @@ -1,10 +1,10 @@ // #NamespaceName: StorageTypes import type { Option } from '../../types' -import type * as Gitification from '../index' +import type { Types as ApiTypes } from '../api' export type AppStorageContextV1 = { - user: Option + user: Option accessToken: Option showOnlyParticipating: boolean openAtStartup: boolean @@ -13,12 +13,12 @@ export type AppStorageContextV1 = { showSystemNotifications: boolean markAsReadOnOpen: boolean colorPreference: ColorPreference - allUsers: Gitification.api.Types.SimpleUser[] - userAccessTokens: Record + allUsers: ApiTypes.SimpleUser[] + userAccessTokens: Record } export type StorageUser = { - user: Gitification.api.Types.SimpleUser + user: ApiTypes.SimpleUser accessToken: string } @@ -37,7 +37,7 @@ export type StorageSettings = { export type AppStorageContextV2 = { version: number - activeUserId: Option + activeUserId: Option users: StorageUser[] settings: StorageSettings } diff --git a/src/gitification/tauri/index.ts b/src/gitification/tauri/index.ts new file mode 100644 index 0000000..1472b1d --- /dev/null +++ b/src/gitification/tauri/index.ts @@ -0,0 +1,82 @@ +import { invoke } from '@tauri-apps/api/core' +import { getCurrentWindow } from '@tauri-apps/api/window' +import * as AutoStart from '@tauri-apps/plugin-autostart' +import { onOpenUrl as listenForOpenUrl } from '@tauri-apps/plugin-deep-link' +import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification' +import { exit, relaunch } from '@tauri-apps/plugin-process' +import { open } from '@tauri-apps/plugin-shell' +import { InvokeCommand } from '../../constants' + +export function createTauriLayer() { + async function showWindow() { + const window = getCurrentWindow() + await window.show() + await window.setFocus() + } + + function openURL(url: string) { + return open(url) + } + + function playNotificationSound() { + return invoke(InvokeCommand.PlayNotificationSound) + } + + function setMenubarIcon(isTemplate: boolean) { + return invoke(InvokeCommand.SetIconTemplate, { isTemplate }) + } + + function requestNotificationPermission() { + return requestPermission() + } + + function hasNotificationPermission() { + return isPermissionGranted() + } + + function notify(notification: { title: string, body: string }) { + return sendNotification(notification) + } + + function quitApp() { + return exit(0) + } + + function relaunchApp() { + return relaunch() + } + + function isAutoStartEnabled() { + return AutoStart.isEnabled() + } + + function enableAutoStart() { + return AutoStart.enable() + } + + function disableAutoStart() { + return AutoStart.disable() + } + + function onOpenUrl(handler: Parameters[0]) { + return listenForOpenUrl(handler) + } + + return { + showWindow, + openURL, + playNotificationSound, + setMenubarIcon, + requestNotificationPermission, + hasNotificationPermission, + notify, + quitApp, + relaunchApp, + isAutoStartEnabled, + enableAutoStart, + disableAutoStart, + onOpenUrl, + } +} + +export type GiTauriLayer = ReturnType diff --git a/src/gitification/utils/github/index.ts b/src/gitification/utils/github/index.ts index 0076ec6..56828d0 100644 --- a/src/gitification/utils/github/index.ts +++ b/src/gitification/utils/github/index.ts @@ -1,125 +1,8 @@ -import type { Options as KyOptions } from 'ky' -import type * as Gitification from '../../index' -import ky from 'ky' - -const api = ky.create({ - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - hooks: { - beforeRequest: [ - (request) => { - const url = new URL(request.url) - url.searchParams.set('t', Date.now().toString()) - - return new Request(url, request) - }, - ], - }, -}) - -export type GithubApiRequestOptions = { - method: NonNullable - searchParams?: NonNullable - headers?: HeadersInit - accessToken: string -} - -export async function sendRequest(url: string, options: GithubApiRequestOptions) { - const { method, searchParams, headers: _headers = {}, accessToken } = options - - const headers = new Headers(_headers) - - headers.set('Authorization', `token ${accessToken}`) - - const response = await api(url, { - method, - searchParams, - headers, - }) - - return [await response.json() as T, response] as const -} - -export function createThreadReferrerId( - notificationId: string, - userId: number, -) { - return window.btoa(`018:NotificationThread${notificationId}:${userId}`) -} - -export type CreateThreadUrlArgs = { - thread: Gitification.api.Types.Thread - user: Gitification.StorageTypes.StorageUser -} - -export async function createThreadHtmlURL({ thread, user }: CreateThreadUrlArgs) { - const notificationReferrerId = createThreadReferrerId(thread.id, user.user.id) - - let url: null | string = null - - if (thread.subject.type === 'CheckSuite') { - url = `https://github.com/${thread.repository.full_name}/actions` - } - else if (thread.subject.type === 'RepositoryInvitation') { - url = `https://github.com/${thread.repository.full_name}/invitations` - } - else if (thread.subject.type === 'Discussion') { - url = (thread.subject.url as string).replace('api.github.com/repos/', `https://github.com/`) - } - else if (thread.subject.type === 'Release') { - url = `https://github.com/${thread.repository.full_name}/releases/` - } - else if (thread.subject.type === 'PullRequest') { - const prId = (thread.subject.url as string) - .split('?')[0] - .split('/') - .at(-1) as string - - url = `https://github.com/${thread.repository.full_name}/pull/${prId}` - } - else if (thread.subject.type === 'Issue') { - const issueId = (thread.subject.url as string) - .split('?')[0] - .split('/') - .at(-1) as string - - url = `https://github.com/${thread.repository.full_name}/issues/${issueId}` - } - else if (thread.subject.type === 'Commit') { - const commitId = (thread.subject.url as string) - .split('?')[0] - .split('/') - .at(-1) as string - - url = `https://github.com/${thread.repository.full_name}/commit/${commitId}` - } - else if (thread.subject.url != null) { - try { - const [data] = await sendRequest<{ html_url?: string }>(thread.subject.url, { - method: 'GET', - accessToken: user.accessToken, - }) - - url = data?.html_url ?? null - } - catch { - url = null - } - } - - if (url == null) { - return null - } - - const uri = new URL(url) - uri.searchParams.set('notification_referrer_id', notificationReferrerId) - - return uri.toString() +export type CreateCodeCallbackURLOptions = { + redirectUri: string } -export function createCodeCallbackURL(redirectUri: string) { +export function createCodeCallbackURL({ redirectUri }: CreateCodeCallbackURLOptions) { const endpoint = 'https://github.com/login/oauth/authorize' const scopes = ['notifications', 'read:user'] diff --git a/src/gitification/utils/notification/index.ts b/src/gitification/utils/notification/index.ts index bae4154..dc3bcf2 100644 --- a/src/gitification/utils/notification/index.ts +++ b/src/gitification/utils/notification/index.ts @@ -1,17 +1,17 @@ -import type * as Gitification from '../../index' +import type { Types as ApiTypes } from '../../api' -export function isThread(value: any): value is Gitification.api.Types.Thread { +export function isThread(value: any): value is ApiTypes.Thread { return typeof value === 'object' && 'reason' in value } -export function isRepository(value: any): value is Gitification.api.Types.MinimalRepository { +export function isRepository(value: any): value is ApiTypes.MinimalRepository { return typeof value === 'object' && 'teams_url' in value } -export function filterThreadsByRepository(threads: Gitification.api.Types.Thread[], repositoryId: number) { +export function filterThreadsByRepository(threads: ApiTypes.Thread[], repositoryId: number) { return threads.filter((thread) => thread.repository.id === repositoryId) } -export function filterCheckedThreads(threads: Gitification.api.Types.Thread[], checkedThreadIds: Set) { +export function filterCheckedThreads(threads: ApiTypes.Thread[], checkedThreadIds: Set) { return threads.filter((thread) => checkedThreadIds.has(thread.id)) } diff --git a/src/ui/Repository/Repository.vue b/src/ui/Repository/Repository.vue index e59defd..f3b1cb5 100644 --- a/src/ui/Repository/Repository.vue +++ b/src/ui/Repository/Repository.vue @@ -1,10 +1,10 @@ diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 546269e..7e632ee 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -1,6 +1,7 @@