diff --git a/src/main/posthog.ts b/src/main/posthog.ts new file mode 100644 index 000000000..ac36710e5 --- /dev/null +++ b/src/main/posthog.ts @@ -0,0 +1,16 @@ +import { env } from "@env" +import Posthog from "posthog-js" + +export const initPosthog = () => { + if (env.VITE_POSTHOG_KEY === undefined) return + Posthog.init(env.VITE_POSTHOG_KEY, { + person_profiles: "identified_only", + debug: true, + }) + return Posthog +} +export const posthog = initPosthog() + +export const trackEvent = (event: string, properties?: Record) => { + posthog?.capture(event, properties) +} diff --git a/src/main/tipc.ts b/src/main/tipc.ts deleted file mode 100644 index 34b763217..000000000 --- a/src/main/tipc.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { createRequire } from "node:module" - -import { getRendererHandlers, tipc } from "@egoist/tipc/main" -import { callGlobalContextMethod } from "@shared/bridge" -import type { BrowserWindow, MessageBoxOptions } from "electron" -import { app, dialog, Menu, nativeTheme, ShareMenu } from "electron" - -import { downloadFile } from "./lib/download" -import type { RendererHandlers } from "./renderer-handlers" -import { quitAndInstall } from "./updater" -import { createSettingWindow, getMainWindow } from "./window" - -const t = tipc.create() - -const require = createRequire(import.meta.url) -export const router = { - inspectElement: t.procedure - .input<{ x: number, y: number }>() - .action(async ({ input, context }) => { - context.sender.inspectElement(input.x, input.y) - }), - - showContextMenu: t.procedure - .input<{ - items: Array< - | { - type: "text" - label: string - enabled?: boolean - - shortcut?: string - icon?: string - } - | { type: "separator" } - > - }>() - .action(async ({ input, context }) => { - const menu = Menu.buildFromTemplate( - input.items.map((item, index) => { - if (item.type === "separator") { - return { - type: "separator" as const, - } - } - return { - label: item.label, - enabled: item.enabled ?? true, - accelerator: item.shortcut, - click() { - context.sender.send("menu-click", index) - }, - } - }), - ) - - menu.popup({ - callback: () => { - context.sender.send("menu-closed") - }, - }) - }), - - showConfirmDialog: t.procedure - .input<{ - title: string - message: string - options?: Partial - }>() - .action(async ({ input }) => { - const result = await dialog.showMessageBox({ - message: input.title, - detail: input.message, - buttons: ["Confirm", "Cancel"], - ...input.options, - }) - return result.response === 0 - }), - - showShareMenu: t.procedure - .input() - .action(async ({ input, context }) => { - const menu = new ShareMenu({ - urls: [input], - }) - - menu.popup({ - callback: () => { - context.sender.send("menu-closed") - }, - }) - }), - - saveToEagle: t.procedure - .input<{ url: string, mediaUrls: string[] }>() - .action(async ({ input }) => { - try { - const res = await fetch("http://localhost:41595/api/item/addFromURLs", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - items: input.mediaUrls?.map((media) => ({ - url: media, - website: input.url, - headers: { - referer: input.url, - }, - })), - }), - }) - return await res.json() - } catch { - return null - } - }), - - invalidateQuery: t.procedure - .input<(string | number | undefined)[]>() - .action(async ({ input }) => { - const mainWindow = getMainWindow() - if (!mainWindow) return - const handlers = getRendererHandlers( - mainWindow.webContents, - ) - handlers.invalidateQuery.send(input) - }), - - getLoginItemSettings: t.procedure - .input() - .action(async () => await app.getLoginItemSettings()), - - setLoginItemSettings: t.procedure - .input() - .action(async ({ input }) => { - app.setLoginItemSettings({ - openAtLogin: input, - }) - }), - - openSettingWindow: t.procedure.action(async () => createSettingWindow()), - - getSystemFonts: t.procedure.action( - async (): Promise => - new Promise((resolve) => { - // NOTE: should external font-list deps - // use `require` to avoid bundling, vite behavior - require("font-list") - .getFonts() - .then((fonts) => { - resolve(fonts.map((font) => font.replaceAll("\"", ""))) - }) - }), - ), - setAppearance: t.procedure - .input<"light" | "dark" | "system">() - .action(async ({ input }) => { - // NOTE: Temporarily changing to system to get the color mode that system is in at the moment may cause a bit of a problem. - // On macos, there is a bug, traffic lights flicker - nativeTheme.themeSource = "system" - const systemColorMode = nativeTheme.shouldUseDarkColors ? - "dark" : - "light" - - nativeTheme.themeSource = systemColorMode === input ? "system" : input - }), - setDockBadge: t.procedure.input().action(async ({ input }) => { - if (app.dock) { - if (input === 0) { - app.dock.setBadge("") - } else { - app.dock.setBadge(input.toString()) - } - } else { - app.setBadgeCount(input) - } - }), - - saveImage: t.procedure.input().action(async ({ input }) => { - const result = await dialog.showSaveDialog({ - defaultPath: input.split("/").pop(), - }) - if (result.canceled) return - - // return result.filePath; - await downloadFile(input, result.filePath).catch((err) => { - const mainWindow = getMainWindow() - if (!mainWindow) return - callGlobalContextMethod(mainWindow, "toast.error", ["Download failed!"]) - throw err - }) - - const mainWindow = getMainWindow() - if (!mainWindow) return - callGlobalContextMethod(mainWindow, "toast.success", ["Download success!"]) - }), - - windowAction: t.procedure - .input<{ action: "close" | "minimize" | "maximum" }>() - .action(async ({ input, context }) => { - if (context.sender.getType() === "window") { - const window: BrowserWindow | null = ( - context.sender as Sender - ).getOwnerBrowserWindow() - - if (!window) return - switch (input.action) { - case "close": { - window.close() - break - } - case "minimize": { - window.minimize() - break - } - case "maximum": { - if (window.isMaximized()) { - window.unmaximize() - } else { - window.maximize() - } - break - } - } - } - }), - quitAndInstall: t.procedure.action(async () => { - quitAndInstall() - }), -} - -interface Sender extends Electron.WebContents { - getOwnerBrowserWindow: () => Electron.BrowserWindow | null -} - -export type Router = typeof router diff --git a/src/main/tipc/_instance.ts b/src/main/tipc/_instance.ts new file mode 100644 index 000000000..8ffae628f --- /dev/null +++ b/src/main/tipc/_instance.ts @@ -0,0 +1,5 @@ +import { tipc } from "@egoist/tipc/main" + +export const t = tipc.create() + +export type T = typeof t diff --git a/src/main/tipc/app.ts b/src/main/tipc/app.ts new file mode 100644 index 000000000..a6d292d1b --- /dev/null +++ b/src/main/tipc/app.ts @@ -0,0 +1,82 @@ +import { getRendererHandlers } from "@egoist/tipc/main" +import type { BrowserWindow } from "electron" + +import type { RendererHandlers } from "../renderer-handlers" +import { quitAndInstall } from "../updater" +import { getMainWindow } from "../window" +import { t } from "./_instance" + +export const appRoute = { + + saveToEagle: t.procedure + .input<{ url: string, mediaUrls: string[] }>() + .action(async ({ input }) => { + try { + const res = await fetch("http://localhost:41595/api/item/addFromURLs", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + items: input.mediaUrls?.map((media) => ({ + url: media, + website: input.url, + headers: { + referer: input.url, + }, + })), + }), + }) + return await res.json() + } catch { + return null + } + }), + + invalidateQuery: t.procedure + .input<(string | number | undefined)[]>() + .action(async ({ input }) => { + const mainWindow = getMainWindow() + if (!mainWindow) return + const handlers = getRendererHandlers( + mainWindow.webContents, + ) + handlers.invalidateQuery.send(input) + }), + + windowAction: t.procedure + .input<{ action: "close" | "minimize" | "maximum" }>() + .action(async ({ input, context }) => { + if (context.sender.getType() === "window") { + const window: BrowserWindow | null = ( + context.sender as Sender + ).getOwnerBrowserWindow() + + if (!window) return + switch (input.action) { + case "close": { + window.close() + break + } + case "minimize": { + window.minimize() + break + } + case "maximum": { + if (window.isMaximized()) { + window.unmaximize() + } else { + window.maximize() + } + break + } + } + } + }), + quitAndInstall: t.procedure.action(async () => { + quitAndInstall() + }), +} +interface Sender extends Electron.WebContents { + getOwnerBrowserWindow: () => Electron.BrowserWindow | null +} diff --git a/src/main/tipc/debug.ts b/src/main/tipc/debug.ts new file mode 100644 index 000000000..ae9e52b39 --- /dev/null +++ b/src/main/tipc/debug.ts @@ -0,0 +1,9 @@ +import { t } from "./_instance" + +export const debugRoute = { + inspectElement: t.procedure + .input<{ x: number, y: number }>() + .action(async ({ input, context }) => { + context.sender.inspectElement(input.x, input.y) + }), +} diff --git a/src/main/tipc/index.ts b/src/main/tipc/index.ts new file mode 100644 index 000000000..5ff7a870c --- /dev/null +++ b/src/main/tipc/index.ts @@ -0,0 +1,15 @@ +import { appRoute } from "./app" +import { debugRoute } from "./debug" +import { menuRoute } from "./menu" +import { settingRoute } from "./setting" +import { trackerRoute } from "./tracker" + +export const router = { + ...debugRoute, + ...menuRoute, + ...settingRoute, + ...appRoute, + ...trackerRoute, +} + +export type Router = typeof router diff --git a/src/main/tipc/menu.ts b/src/main/tipc/menu.ts new file mode 100644 index 000000000..9372274aa --- /dev/null +++ b/src/main/tipc/menu.ts @@ -0,0 +1,99 @@ +import { callGlobalContextMethod } from "@shared/bridge" +import type { MessageBoxOptions } from "electron" +import { dialog, Menu, ShareMenu } from "electron" + +import { downloadFile } from "../lib/download" +import { getMainWindow } from "../window" +import { t } from "./_instance" + +export const menuRoute = { + showContextMenu: t.procedure + .input<{ + items: Array< + | { + type: "text" + label: string + enabled?: boolean + + shortcut?: string + icon?: string + } + | { type: "separator" } + > + }>() + .action(async ({ input, context }) => { + const menu = Menu.buildFromTemplate( + input.items.map((item, index) => { + if (item.type === "separator") { + return { + type: "separator" as const, + } + } + return { + label: item.label, + enabled: item.enabled ?? true, + accelerator: item.shortcut, + click() { + context.sender.send("menu-click", index) + }, + } + }), + ) + + menu.popup({ + callback: () => { + context.sender.send("menu-closed") + }, + }) + }), + + /** @deprecated */ + showConfirmDialog: t.procedure + .input<{ + title: string + message: string + options?: Partial + }>() + .action(async ({ input }) => { + const result = await dialog.showMessageBox({ + message: input.title, + detail: input.message, + buttons: ["Confirm", "Cancel"], + ...input.options, + }) + return result.response === 0 + }), + + showShareMenu: t.procedure + .input() + .action(async ({ input, context }) => { + const menu = new ShareMenu({ + urls: [input], + }) + + menu.popup({ + callback: () => { + context.sender.send("menu-closed") + }, + }) + }), + + saveImage: t.procedure.input().action(async ({ input }) => { + const result = await dialog.showSaveDialog({ + defaultPath: input.split("/").pop(), + }) + if (result.canceled) return + + // return result.filePath; + await downloadFile(input, result.filePath).catch((err) => { + const mainWindow = getMainWindow() + if (!mainWindow) return + callGlobalContextMethod(mainWindow, "toast.error", ["Download failed!"]) + throw err + }) + + const mainWindow = getMainWindow() + if (!mainWindow) return + callGlobalContextMethod(mainWindow, "toast.success", ["Download success!"]) + }), +} diff --git a/src/main/tipc/setting.ts b/src/main/tipc/setting.ts new file mode 100644 index 000000000..d4fedb870 --- /dev/null +++ b/src/main/tipc/setting.ts @@ -0,0 +1,46 @@ +import { app, nativeTheme } from "electron" + +import { createSettingWindow } from "../window" +import { t } from "./_instance" + +export const settingRoute = { getLoginItemSettings: t.procedure + .input() + .action(async () => await app.getLoginItemSettings()), setLoginItemSettings: t.procedure + .input() + .action(async ({ input }) => { + app.setLoginItemSettings({ + openAtLogin: input, + }) + }), openSettingWindow: t.procedure.action(async () => createSettingWindow()), getSystemFonts: t.procedure.action( + async (): Promise => + new Promise((resolve) => { + // NOTE: should external font-list deps + // use `require` to avoid bundling, vite behavior + require("font-list") + .getFonts() + .then((fonts) => { + resolve(fonts.map((font) => font.replaceAll("\"", ""))) + }) + }), +), setAppearance: t.procedure + .input<"light" | "dark" | "system">() + .action(async ({ input }) => { + // NOTE: Temporarily changing to system to get the color mode that system is in at the moment may cause a bit of a problem. + // On macos, there is a bug, traffic lights flicker + nativeTheme.themeSource = "system" + const systemColorMode = nativeTheme.shouldUseDarkColors ? + "dark" : + "light" + + nativeTheme.themeSource = systemColorMode === input ? "system" : input + }), setDockBadge: t.procedure.input().action(async ({ input }) => { + if (app.dock) { + if (input === 0) { + app.dock.setBadge("") + } else { + app.dock.setBadge(input.toString()) + } + } else { + app.setBadgeCount(input) + } +}) } diff --git a/src/main/tipc/tracker.ts b/src/main/tipc/tracker.ts new file mode 100644 index 000000000..1fd1f1e17 --- /dev/null +++ b/src/main/tipc/tracker.ts @@ -0,0 +1,19 @@ +import type { User } from "@auth/core/types" + +import { posthog } from "../posthog" +import { t } from "./_instance" + +export const trackerRoute = { + trackerIdentify: t.procedure + .input<{ + user: User + }>() + .action(async ({ input }) => { + const { user } = input + + posthog?.identify(user.id, { + name: user.name, + handle: user.handle, + }) + }), +} diff --git a/src/main/updater/index.ts b/src/main/updater/index.ts index 292ec15b7..7b6898f04 100644 --- a/src/main/updater/index.ts +++ b/src/main/updater/index.ts @@ -3,6 +3,7 @@ import { autoUpdater } from "electron-updater" import { channel, isDev } from "../env" import { logger } from "../logger" +import { trackEvent } from "../posthog" import type { RendererHandlers } from "../renderer-handlers" import { getMainWindow } from "../window" import { CustomGitHubProvider } from "./custom-github-provider" @@ -67,16 +68,17 @@ export const registerUpdater = async () => { autoUpdater.autoInstallOnAppQuit = true autoUpdater.autoRunAppAfterInstall = true - const feedUrl: Exclude[0], string> = { - channel, - // hack for custom provider - provider: "custom" as "github", - repo: "follow", - owner: "RSSNext", - releaseType: channel === "stable" ? "release" : "prerelease", - // @ts-expect-error hack for custom provider - updateProvider: CustomGitHubProvider, - } + const feedUrl: Exclude[0], string> = + { + channel, + // hack for custom provider + provider: "custom" as "github", + repo: "follow", + owner: "RSSNext", + releaseType: channel === "stable" ? "release" : "prerelease", + // @ts-expect-error hack for custom provider + updateProvider: CustomGitHubProvider, + } logger.debug("auto-updater feed config", { ...feedUrl, @@ -103,7 +105,7 @@ export const registerUpdater = async () => { autoUpdater.on("download-progress", (e) => { logger.info(`Download progress: ${e.percent}`) }) - autoUpdater.on("update-downloaded", () => { + autoUpdater.on("update-downloaded", (e) => { downloading = false logger.info("Update downloaded, ready to install") @@ -112,6 +114,11 @@ export const registerUpdater = async () => { const handlers = getRendererHandlers( mainWindow.webContents, ) + + trackEvent("update_ready_to_install", { + version: e.version, + releaseVersion: e.releaseName, + }) handlers.updateDownloaded.send() }) autoUpdater.on("error", (e) => { diff --git a/src/renderer/src/modules/feed-column/auto-updater.tsx b/src/renderer/src/modules/feed-column/auto-updater.tsx index e3d043c87..c6bc6dcee 100644 --- a/src/renderer/src/modules/feed-column/auto-updater.tsx +++ b/src/renderer/src/modules/feed-column/auto-updater.tsx @@ -17,6 +17,8 @@ export const AutoUpdater = () => { const handleClick = useCallback(() => { setUpdaterStatus(false) + window.posthog?.capture("update_restart") + tipcClient?.quitAndInstall() }, []) diff --git a/src/renderer/src/providers/user-provider.tsx b/src/renderer/src/providers/user-provider.tsx index 0890261bc..2b4321734 100644 --- a/src/renderer/src/providers/user-provider.tsx +++ b/src/renderer/src/providers/user-provider.tsx @@ -1,4 +1,5 @@ import { useSetMe } from "@renderer/atoms/user" +import { tipcClient } from "@renderer/lib/client" import { useSession } from "@renderer/queries/auth" import { CleanerService } from "@renderer/services/cleaner" import { useEffect } from "react" @@ -15,6 +16,9 @@ export const UserProvider = () => { handle: session.user.handle, }) + tipcClient?.trackerIdentify({ + user: session.user, + }) CleanerService.cleanRemainingData(session.user.id) }, [session?.user, setUser])