diff --git a/.github/workflows/build-render.yml b/.github/workflows/build-render.yml new file mode 100644 index 0000000000..4540694821 --- /dev/null +++ b/.github/workflows/build-render.yml @@ -0,0 +1,75 @@ +name: Build Electron Render + +on: + push: + tags: + - "v*" + +env: + VITE_WEB_URL: ${{ vars.VITE_WEB_URL }} + VITE_API_URL: ${{ vars.VITE_API_URL }} + VITE_IMGPROXY_URL: ${{ vars.VITE_IMGPROXY_URL }} + VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + VITE_OPENPANEL_CLIENT_ID: ${{ vars.VITE_OPENPANEL_CLIENT_ID }} + VITE_OPENPANEL_API_URL: ${{ vars.VITE_OPENPANEL_API_URL }} + VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }} + NODE_OPTIONS: --max-old-space-size=8192 + +jobs: + build-render: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + + permissions: + id-token: write + contents: write + attestations: write + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + with: + lfs: true + + - name: Cache pnpm modules + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ matrix.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ matrix.os }}- + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm i + - name: Build + run: pnpm build:render + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + + - name: Setup Version + id: version + uses: ./.github/actions/setup-version + + - name: Create Release Draft + uses: softprops/action-gh-release@v2 + with: + name: v${{ steps.version.outputs.APP_VERSION }} + draft: false + prerelease: true + tag_name: v${{ steps.version.outputs.APP_VERSION }} + files: | + dist/manifest.yml + dist/*.tar.gz diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 882e633196..a5121fe827 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -103,7 +103,8 @@ jobs: - name: Install dependencies run: pnpm i - + - name: Update main hash + run: pnpm update:main-hash - name: Build if: matrix.os != 'macos-latest' run: npm exec turbo run //#build diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index daf79a6348..bf713316f8 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -72,25 +72,20 @@ jobs: CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 PP_PATH=$RUNNER_TEMP/build_pp.provisionprofile KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db - # import certificate and provisioning profile from secrets echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH - # create temporary keychain security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - # import certificate to keychain security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH - # apply provisioning profile mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles - - name: Install dependencies run: pnpm i @@ -115,6 +110,9 @@ jobs: fi echo "Updated version to $NIGHTLY_VERSION" + - name: Update main hash + run: pnpm update:main-hash + - name: Build if: matrix.os != 'macos-latest' run: pnpm build @@ -130,6 +128,10 @@ jobs: KEYCHAIN_PATH: ${{ runner.temp }}/app-signing.keychain-db run: pnpm build:macos + - name: Build (Render) + run: pnpm build:render + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: Upload artifacts uses: actions/upload-artifact@v4 with: @@ -140,6 +142,9 @@ jobs: out/make/**/*.AppImage out/make/**/*.yml out/make/**/*.dmg + dist/manifest.yml + dist/*.tar.gz + retention-days: 7 - name: Generate artifact attestation @@ -152,6 +157,8 @@ jobs: out/make/**/*.exe out/make/**/*.AppImage out/make/**/*.yml + dist/manifest.yml + dist/*.tar.gz - name: Create Nightly Release uses: softprops/action-gh-release@v2 @@ -159,16 +166,19 @@ jobs: name: Nightly ${{ env.NIGHTLY_VERSION }} draft: false prerelease: true - tag_name: nightly-${{ env.NIGHTLY_VERSION }} + tag_name: ${{ env.NIGHTLY_VERSION }} files: | out/make/**/*.dmg out/make/**/*.zip out/make/**/*.exe out/make/**/*.AppImage out/make/**/*.yml + dist/manifest.yml + dist/*.tar.gz + body: | This is an automated nightly release for testing purposes. - Version: 0.0.0-nightly.${{ env.NIGHTLY_VERSION }} + Version: ${{ env.NIGHTLY_VERSION }} **Warning:** This build may be unstable and is not recommended for production use. env: diff --git a/apps/main/global.d.ts b/apps/main/global.d.ts index 2f2956a52b..fb3018c3d6 100644 --- a/apps/main/global.d.ts +++ b/apps/main/global.d.ts @@ -1,2 +1,7 @@ import "../../types/vite" import "../../types/authjs" + +declare global { + const GIT_COMMIT_HASH: string +} +export {} diff --git a/apps/main/package.json b/apps/main/package.json index 8a62b826b2..2e7ad71a9f 100644 --- a/apps/main/package.json +++ b/apps/main/package.json @@ -28,23 +28,29 @@ "@eneris/push-receiver": "4.3.0", "@follow/shared": "workspace:*", "@mozilla/readability": "^0.5.0", + "@openpanel/web": "1.0.1", "@sentry/electron": "5.7.0", "builder-util-runtime": "9.2.10", "electron-context-menu": "4.0.4", "electron-log": "5.2.2", "electron-squirrel-startup": "1.0.1", "electron-updater": "^6.3.9", + "es-toolkit": "1.26.1", "fast-folder-size": "2.3.0", "font-list": "1.5.1", "i18next": "^24.0.0", + "js-yaml": "4.1.0", "linkedom": "^0.18.5", "lowdb": "7.0.1", "msedge-tts": "1.3.4", + "node-machine-id": "1.1.12", "ofetch": "1.4.1", "semver": "7.6.3", + "tar": "7.4.3", "vscode-languagedetection": "npm:@vscode/vscode-languagedetection@^1.0.22" }, "devDependencies": { + "@types/js-yaml": "4.0.9", "@types/node": "^22.9.3", "electron": "33.2.0", "electron-devtools-installer": "3.2.0", diff --git a/apps/main/src/constants/app.ts b/apps/main/src/constants/app.ts index 9f9441bdc8..26dcaa5b46 100644 --- a/apps/main/src/constants/app.ts +++ b/apps/main/src/constants/app.ts @@ -1,5 +1,13 @@ -// 5min +import path from "node:path" + +import { app } from "electron" + export const UNREAD_BACKGROUND_POLLING_INTERVAL = 1000 * 60 * 5 +export const HOTUPDATE_RENDER_ENTRY_DIR = path.resolve(app.getPath("userData"), "render") + +export const GITHUB_OWNER = process.env.GITHUB_OWNER || "RSSNext" +export const GITHUB_REPO = process.env.GITHUB_REPO || "follow" + // https://github.com/electron/electron/issues/25081 export const START_IN_TRAY_ARGS = "--start-in-tray" diff --git a/apps/main/src/constants/system.ts b/apps/main/src/constants/system.ts new file mode 100644 index 0000000000..5cb751c988 --- /dev/null +++ b/apps/main/src/constants/system.ts @@ -0,0 +1,3 @@ +import { machineIdSync } from "node-machine-id" + +export const DEVICE_ID = machineIdSync() diff --git a/apps/main/src/index.ts b/apps/main/src/index.ts index 3c79df140c..3cc590da52 100644 --- a/apps/main/src/index.ts +++ b/apps/main/src/index.ts @@ -4,8 +4,10 @@ import { APP_PROTOCOL } from "@follow/shared/constants" import { env } from "@follow/shared/env" import { imageRefererMatches, selfRefererMatches } from "@follow/shared/image" import { app, BrowserWindow, session } from "electron" +import type { Cookie } from "electron/main" import squirrelStartup from "electron-squirrel-startup" +import { DEVICE_ID } from "./constants/system" import { isDev, isMacOS } from "./env" import { initializeAppStage0, initializeAppStage1 } from "./init" import { updateProxy } from "./lib/proxy" @@ -14,6 +16,7 @@ import { store } from "./lib/store" import { registerAppTray } from "./lib/tray" import { setAuthSessionToken, updateNotificationsToken } from "./lib/user" import { registerUpdater } from "./updater" +import { cleanupOldRender } from "./updater/hot-updater" import { createMainWindow, getMainWindow, @@ -23,6 +26,9 @@ import { if (isDev) console.info("[main] env loaded:", env) +const apiURL = process.env["VITE_API_URL"] || import.meta.env.VITE_API_URL + +console.info("[main] device id:", DEVICE_ID) if (squirrelStartup) { app.quit() } @@ -56,7 +62,7 @@ function bootstrap() { // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. - app.whenReady().then(() => { + app.whenReady().then(async () => { // Default open or close DevTools by F12 in development // and ignore CommandOrControl + R in production. // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils @@ -69,6 +75,28 @@ function bootstrap() { mainWindow = createMainWindow() + // restore cookies + const cookies = store.get("cookies") as Cookie[] + if (cookies) { + Promise.all( + cookies.map((cookie) => { + const setCookieDetails: Electron.CookiesSetDetails = { + url: apiURL, + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + expirationDate: cookie.expirationDate, + sameSite: cookie.sameSite as "unspecified" | "no_restriction" | "lax" | "strict", + } + + return mainWindow.webContents.session.cookies.set(setCookieDetails) + }), + ) + } + updateProxy() registerUpdater() registerAppTray() @@ -135,7 +163,7 @@ function bootstrap() { } }) - app.on("before-quit", () => { + app.on("before-quit", async () => { // store window pos when before app quit const window = getMainWindow() if (!window || window.isDestroyed()) return @@ -147,6 +175,12 @@ function bootstrap() { x: bounds.x, y: bounds.y, }) + await session.defaultSession.cookies.flushStore() + + const cookies = await session.defaultSession.cookies.get({}) + store.set("cookies", cookies) + + await cleanupOldRender() }) const handleOpen = async (url: string) => { @@ -158,7 +192,6 @@ function bootstrap() { const token = urlObj.searchParams.get("token") const userId = urlObj.searchParams.get("userId") - const apiURL = process.env["VITE_API_URL"] || import.meta.env.VITE_API_URL if (token && apiURL) { setAuthSessionToken(token) mainWindow.webContents.session.cookies.set({ diff --git a/apps/main/src/init.ts b/apps/main/src/init.ts index 3c3bff55cf..2358554fad 100644 --- a/apps/main/src/init.ts +++ b/apps/main/src/init.ts @@ -58,7 +58,6 @@ export const initializeAppStage1 = () => { // code. You can also put them in separate files and require them here. registerMenuAndContextMenu() - registerPushNotifications() clearCacheCronJob() checkAndCleanCodeCache() diff --git a/apps/main/src/lib/op.ts b/apps/main/src/lib/op.ts new file mode 100644 index 0000000000..f068f6cf39 --- /dev/null +++ b/apps/main/src/lib/op.ts @@ -0,0 +1,20 @@ +import { env } from "@follow/shared/env" +import { OpenPanel } from "@openpanel/web" +import { app } from "electron" + +import { DEVICE_ID } from "~/constants/system" + +export const op = new OpenPanel({ + clientId: env.VITE_OPENPANEL_CLIENT_ID ?? "", + trackScreenViews: false, + trackOutgoingLinks: false, + trackAttributes: false, + trackHashChanges: false, + apiUrl: env.VITE_OPENPANEL_API_URL, +}) + +op.setGlobalProperties({ + device_id: DEVICE_ID, + app_version: app.getVersion(), + build: "electron", +}) diff --git a/apps/main/src/lib/utils.ts b/apps/main/src/lib/utils.ts index ae79457346..12b3d133d0 100644 --- a/apps/main/src/lib/utils.ts +++ b/apps/main/src/lib/utils.ts @@ -1 +1,13 @@ +import type { BrowserWindow } from "electron" + export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +// To solve the vibrancy losing issue when leaving full screen mode +// @see https://github.com/toeverything/AFFiNE/blob/280e24934a27557529479a70ab38c4f5fc65cb00/packages/frontend/electron/src/main/windows-manager/main-window.ts:L157 +export function refreshBound(window: BrowserWindow, timeout = 0) { + setTimeout(() => { + // FIXME: workaround for theme bug in full screen mode + const size = window?.getSize() + window?.setSize(size[0] + 1, size[1] + 1) + window?.setSize(size[0], size[1]) + }, timeout) +} diff --git a/apps/main/src/menu.ts b/apps/main/src/menu.ts index 1a73f46fe5..d65470a946 100644 --- a/apps/main/src/menu.ts +++ b/apps/main/src/menu.ts @@ -8,7 +8,7 @@ import { isDev, isMacOS } from "./env" import { clearAllDataAndConfirm } from "./lib/cleaner" import { t } from "./lib/i18n" import { revealLogFile } from "./logger" -import { checkForUpdates, quitAndInstall } from "./updater" +import { checkForAppUpdates, quitAndInstall } from "./updater" import { createSettingWindow, createWindow, getMainWindow } from "./window" export const registerAppMenu = () => { @@ -184,7 +184,7 @@ export const registerAppMenu = () => { label: t("menu.checkForUpdates"), click: async () => { getMainWindow()?.show() - await checkForUpdates() + await checkForAppUpdates() }, }, ], diff --git a/apps/main/src/sentry.ts b/apps/main/src/sentry.ts index 22000ee6ed..5337d02070 100644 --- a/apps/main/src/sentry.ts +++ b/apps/main/src/sentry.ts @@ -1,6 +1,9 @@ import * as Sentry from "@sentry/electron/main" +import { app } from "electron" import { FetchError } from "ofetch" +import { DEVICE_ID } from "./constants/system" + export const initializeSentry = () => { Sentry.init({ dsn: process.env.VITE_SENTRY_DSN, @@ -32,4 +35,7 @@ export const initializeSentry = () => { return event }, }) + Sentry.setTag("device_id", DEVICE_ID) + Sentry.setTag("app_version", app.getVersion()) + Sentry.setTag("build", "electron") } diff --git a/apps/main/src/tipc/app.ts b/apps/main/src/tipc/app.ts index e064653a80..8ce4e7cecb 100644 --- a/apps/main/src/tipc/app.ts +++ b/apps/main/src/tipc/app.ts @@ -1,19 +1,26 @@ +/* eslint-disable @typescript-eslint/require-await */ import fs from "node:fs" import fsp from "node:fs/promises" import path from "node:path" +import { fileURLToPath } from "node:url" import { getRendererHandlers } from "@egoist/tipc/main" import { callWindowExpose } from "@follow/shared/bridge" -import type { BrowserWindow } from "electron" -import { app, clipboard, dialog, screen, shell } from "electron" +import pkg from "@pkg" +import { app, BrowserWindow, clipboard, dialog, screen, shell } from "electron" import { registerMenuAndContextMenu } from "~/init" import { clearAllData, getCacheSize } from "~/lib/cleaner" import { store, StoreKey } from "~/lib/store" import { registerAppTray } from "~/lib/tray" import { logger } from "~/logger" +import { + cleanupOldRender, + getCurrentRenderManifest, + loadDynamicRenderEntry, +} from "~/updater/hot-updater" -import { isWindows11 } from "../env" +import { isDev, isWindows11 } from "../env" import { downloadFile } from "../lib/download" import { i18n } from "../lib/i18n" import { cleanAuthSessionToken, cleanUser } from "../lib/user" @@ -272,6 +279,34 @@ ${content} } }), + getRenderVersion: t.procedure.action(async () => { + const manifest = getCurrentRenderManifest() + return manifest?.version || pkg.version + }), + rendererUpdateReload: t.procedure.action(async () => { + const __dirname = fileURLToPath(new URL(".", import.meta.url)) + const allWindows = BrowserWindow.getAllWindows() + const dynamicRenderEntry = loadDynamicRenderEntry() + + const appLoadEntry = dynamicRenderEntry || path.resolve(__dirname, "../../renderer/index.html") + logger.info("appLoadEntry", appLoadEntry) + const mainWindow = getMainWindow() + + for (const window of allWindows) { + if (window === mainWindow) { + if (isDev) { + logger.verbose("[rendererUpdateReload]: skip reload in dev") + break + } + window.loadFile(appLoadEntry) + } else window.destroy() + } + + setTimeout(() => { + cleanupOldRender() + }, 1000) + }), + getCacheSize: t.procedure.action(async () => { return getCacheSize() }), diff --git a/apps/main/src/tracker/index.ts b/apps/main/src/tracker/index.ts new file mode 100644 index 0000000000..0f2522b1b7 --- /dev/null +++ b/apps/main/src/tracker/index.ts @@ -0,0 +1,16 @@ +import { op } from "~/lib/op" + +const PREFIX = "app:" +export const hotUpdateDownloadTrack = (version: string) => { + op.track(`${PREFIX}hot-update-download`, { version }) +} +export const hotUpdateAppNotSupportTriggerTrack = (data: { + appVersion: string + manifestVersion: string +}) => { + op.track(`${PREFIX}hot-update-app-not-support-trigger`, data) +} + +export const hotUpdateRenderSuccessTrack = (version: string) => { + op.track(`${PREFIX}hot-update-render-success`, { version }) +} diff --git a/apps/main/src/updater/configs.ts b/apps/main/src/updater/configs.ts new file mode 100644 index 0000000000..2d216fa318 --- /dev/null +++ b/apps/main/src/updater/configs.ts @@ -0,0 +1,19 @@ +import { version as appVersion } from "@pkg" + +import { isDev } from "~/env" + +const isNightlyBuild = appVersion.includes("nightly") + +export const appUpdaterConfig = { + // Disable renderer hot update will trigger app update when available + enableRenderHotUpdate: !isDev && isNightlyBuild, + // Disable app update will also disable renderer hot update + // enableAppUpdate: true, + enableAppUpdate: !isDev, + + app: { + autoCheckUpdate: true, + autoDownloadUpdate: true, + checkUpdateInterval: 15 * 60 * 1000, + }, +} diff --git a/apps/main/src/updater/hot-updater.ts b/apps/main/src/updater/hot-updater.ts new file mode 100644 index 0000000000..618d4d66ea --- /dev/null +++ b/apps/main/src/updater/hot-updater.ts @@ -0,0 +1,229 @@ +/** + * @description This file handles hot updates for the electron renderer layer + */ +import { createHash } from "node:crypto" +import { existsSync, readFileSync } from "node:fs" +import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +import { callWindowExpose } from "@follow/shared/bridge" +import { mainHash, version as appVersion } from "@pkg" +import log from "electron-log" +import { load } from "js-yaml" +import { x } from "tar" + +import { GITHUB_OWNER, GITHUB_REPO, HOTUPDATE_RENDER_ENTRY_DIR } from "~/constants/app" +import { hotUpdateDownloadTrack, hotUpdateRenderSuccessTrack } from "~/tracker" +import { getMainWindow } from "~/window" + +import { appUpdaterConfig } from "./configs" +import type { GitHubReleasesItem } from "./types" + +const logger = log.scope("hot-updater") + +const isNightlyBuild = appVersion.includes("nightly") + +const url = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}` +const releasesUrl = `${url}/releases` +const releaseApiUrl = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases` + +const getLatestReleaseTag = async () => { + if (!isNightlyBuild) { + const res = await fetch(`${releaseApiUrl}/latest`) + const json = await res.json() + + return json.tag_name + } else { + const res = await fetch(releaseApiUrl) + const json = (await res.json()) as GitHubReleasesItem[] + + // Search the top nightly release + const nightlyRelease = json.find((item) => item.prerelease) + if (!nightlyRelease) return json[0].tag_name + return nightlyRelease.tag_name + } +} + +const getFileDownloadUrl = async (filename: string) => { + const tag = await getLatestReleaseTag() + + return `${releasesUrl}/download/${tag}/${filename}` +} + +type Manifest = { + /** Render version */ + version: string + hash: string + commit: string + filename: string + /** Only electron main hash equal to this value, renderer will can be updated */ + mainHash: string +} +const getLatestReleaseManifest = async () => { + const url = await getFileDownloadUrl("manifest.yml") + logger.info(`Fetching manifest from ${url}`) + const res = await fetch(url) + const text = await res.text() + const manifest = load(text) as Manifest + if (typeof manifest !== "object") { + logger.error("Invalid manifest", text) + return null + } + return manifest +} +const downloadTempDir = path.resolve(os.tmpdir(), "follow-render-update") + +export const canUpdateRender = async () => { + const manifest = await getLatestReleaseManifest() + + logger.info("fetched manifest", manifest) + + if (!manifest) return false + + // const isAppShouldUpdate = shouldUpdateApp(appVersion, manifest.version) + // if (isAppShouldUpdate) { + // logger.info( + // "app should update, skip render update, app version: ", + // appVersion, + // ", the manifest version: ", + // manifest.version, + // ) + // return false + // } + + const appSupport = mainHash === manifest.mainHash + if (!appSupport) { + logger.info("app not support, should trigger app force update, app version: ", appVersion) + // hotUpdateAppNotSupportTriggerTrack({ + // appVersion, + // manifestVersion: manifest.version, + // }) + // // Trigger app force update + // checkForAppUpdates().then(() => { + // downloadAppUpdate() + // }) + return false + } + + const isVersionEqual = appVersion === manifest.version + if (isVersionEqual) { + logger.info("version is equal, skip update") + return false + } + const isCommitEqual = GIT_COMMIT_HASH === manifest.commit + if (isCommitEqual) { + logger.info("commit is equal, skip update") + return false + } + + const manifestFilePath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml") + const manifestExist = existsSync(manifestFilePath) + + const oldManifest: Manifest | null = manifestExist + ? (load(readFileSync(manifestFilePath, "utf-8")) as Manifest) + : null + + if (oldManifest) { + if (oldManifest.version === manifest.version) { + logger.info("manifest version is equal, skip update") + return false + } + if (oldManifest.commit === manifest.commit) { + logger.info("manifest commit is equal, skip update") + return false + } + } + return manifest +} +const downloadRenderAsset = async (manifest: Manifest) => { + hotUpdateDownloadTrack(manifest.version) + const { filename } = manifest + const url = await getFileDownloadUrl(filename) + + logger.info(`Downloading ${url}`) + const res = await fetch(url) + const arrayBuffer = await res.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + const filePath = path.resolve(downloadTempDir, filename) + await mkdir(downloadTempDir, { recursive: true }) + await writeFile(filePath, buffer) + + const sha256 = createHash("sha256") + sha256.update(buffer) + const hash = sha256.digest("hex") + if (hash !== manifest.hash) { + logger.error("Hash mismatch", hash, manifest.hash) + return false + } + return filePath +} +export const hotUpdateRender = async (manifest: Manifest) => { + if (!appUpdaterConfig.enableRenderHotUpdate) return false + + if (!manifest) return false + + const filePath = await downloadRenderAsset(manifest) + if (!filePath) return false + + // Extract the tar.gz file + await mkdir(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true }) + await x({ + f: filePath, + cwd: HOTUPDATE_RENDER_ENTRY_DIR, + }) + + // Rename `renderer` folder to `manifest.version` + await rename( + path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "renderer"), + path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version), + ) + + await writeFile( + path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml"), + JSON.stringify(manifest), + ) + logger.info(`Hot update render success, update to ${manifest.version}`) + hotUpdateRenderSuccessTrack(manifest.version) + const mainWindow = getMainWindow() + if (!mainWindow) return false + const caller = callWindowExpose(mainWindow) + caller.readyToUpdate() + return true +} + +export const getCurrentRenderManifest = () => { + const manifestFilePath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml") + const manifestExist = existsSync(manifestFilePath) + if (!manifestExist) return null + return load(readFileSync(manifestFilePath, "utf-8")) as Manifest +} +export const cleanupOldRender = async () => { + const manifest = getCurrentRenderManifest() + if (!manifest) { + // Empty the directory + await rm(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true, force: true }) + return + } + + const currentRenderVersion = manifest.version + // Clean all not current version + const dirs = await readdir(HOTUPDATE_RENDER_ENTRY_DIR) + for (const dir of dirs) { + const isDir = (await stat(path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, dir))).isDirectory() + if (!isDir) continue + if (dir === currentRenderVersion) continue + await rm(path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, dir), { recursive: true, force: true }) + } +} + +export const loadDynamicRenderEntry = () => { + const manifest = getCurrentRenderManifest() + if (!manifest) return + const currentRenderVersion = manifest.version + const dir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, currentRenderVersion) + const entryFile = path.resolve(dir, "index.html") + const entryFileExists = existsSync(entryFile) + if (!entryFileExists) return + return entryFile +} diff --git a/apps/main/src/updater/index.ts b/apps/main/src/updater/index.ts index 9f74934079..115f65c8e5 100644 --- a/apps/main/src/updater/index.ts +++ b/apps/main/src/updater/index.ts @@ -1,15 +1,20 @@ import { getRendererHandlers } from "@egoist/tipc/main" import { autoUpdater as defaultAutoUpdater } from "electron-updater" +import { GITHUB_OWNER, GITHUB_REPO } from "~/constants/app" +import { canUpdateRender, hotUpdateRender } from "~/updater/hot-updater" + import { channel, isDev, isWindows } from "../env" import { logger } from "../logger" import type { RendererHandlers } from "../renderer-handlers" import { destroyMainWindow, getMainWindow } from "../window" +import { appUpdaterConfig } from "./configs" import { CustomGitHubProvider } from "./custom-github-provider" import { WindowsUpdater } from "./windows-updater" // skip auto update in dev mode -const disabled = isDev +// const disabled = isDev +const disabled = !appUpdaterConfig.enableAppUpdate const autoUpdater = isWindows ? new WindowsUpdater() : defaultAutoUpdater @@ -28,32 +33,29 @@ export const quitAndInstall = () => { let downloading = false let checkingUpdate = false -export type UpdaterConfig = { - autoCheckUpdate: boolean - autoDownloadUpdate: boolean - checkUpdateInterval: number -} - -const config: UpdaterConfig = { - autoCheckUpdate: true, - autoDownloadUpdate: true, - checkUpdateInterval: 15 * 60 * 1000, -} - -export const checkForUpdates = async () => { +export const checkForAppUpdates = async () => { if (disabled || checkingUpdate) { return } + checkingUpdate = true try { - const info = await autoUpdater.checkForUpdates() - return info + if (appUpdaterConfig.enableRenderHotUpdate) { + const manifest = await canUpdateRender() + if (manifest) { + await hotUpdateRender(manifest) + return + } + } + return autoUpdater.checkForUpdates() + } catch (e) { + logger.error("Error checking for updates", e) } finally { checkingUpdate = false } } -export const downloadUpdate = async () => { +export const downloadAppUpdate = async () => { if (disabled || downloading) { return } @@ -71,8 +73,7 @@ export const registerUpdater = async () => { return } - const allowAutoUpdate = true - + // Disable there, control this in event autoUpdater.autoDownload = false autoUpdater.allowPrerelease = channel !== "stable" autoUpdater.autoInstallOnAppQuit = true @@ -82,8 +83,8 @@ export const registerUpdater = async () => { channel, // hack for custom provider provider: "custom" as "github", - repo: "follow", - owner: "RSSNext", + repo: GITHUB_REPO, + owner: GITHUB_OWNER, releaseType: channel === "stable" ? "release" : "prerelease", // @ts-expect-error hack for custom provider updateProvider: CustomGitHubProvider, @@ -100,10 +101,22 @@ export const registerUpdater = async () => { autoUpdater.on("checking-for-update", () => { logger.info("Checking for update") }) - autoUpdater.on("update-available", (info) => { + autoUpdater.on("update-available", async (info) => { logger.info("Update available", info) - if (config.autoDownloadUpdate && allowAutoUpdate) { - downloadUpdate().catch((err) => { + + // The app hotfix strategy is as follows: + // Determine whether the app should be updated in full or only the renderer layer based on the version number. + // https://www.notion.so/rss3/Follow-Hotfix-Electron-Renderer-layer-RFC-fe2444b9ac194c2cb38f9fa0bb1ef3c1?pvs=4#12e35ea049b480f1b268f1e605d86a62 + if (appUpdaterConfig.enableRenderHotUpdate) { + const manifest = await canUpdateRender() + if (manifest) { + await hotUpdateRender(manifest) + return + } + } + + if (appUpdaterConfig.app.autoDownloadUpdate) { + downloadAppUpdate().catch((err) => { logger.error(err) }) } @@ -130,14 +143,14 @@ export const registerUpdater = async () => { autoUpdater.forceDevUpdateConfig = isDev setInterval(() => { - if (config.autoCheckUpdate) { - checkForUpdates().catch((err) => { + if (appUpdaterConfig.app.autoCheckUpdate) { + checkForAppUpdates().catch((err) => { logger.error("Error checking for updates", err) }) } - }, config.checkUpdateInterval) - if (config.autoCheckUpdate) { - checkForUpdates().catch((err) => { + }, appUpdaterConfig.app.checkUpdateInterval) + if (appUpdaterConfig.app.autoCheckUpdate) { + checkForAppUpdates().catch((err) => { logger.error("Error checking for updates", err) }) } diff --git a/apps/main/src/updater/types.ts b/apps/main/src/updater/types.ts new file mode 100644 index 0000000000..7a9cb54d74 --- /dev/null +++ b/apps/main/src/updater/types.ts @@ -0,0 +1,91 @@ +export interface GitHubReleasesItem { + url: string + assets_url: string + upload_url: string + html_url: string + id: number + author: Author + node_id: string + tag_name: string + target_commitish: string + name: string + draft: boolean + prerelease: boolean + created_at: string + published_at: string + assets: AssetsItem[] + tarball_url: string + zipball_url: string + body: string + reactions?: Reactions + mentions_count?: number +} +interface Author { + login: string + id: number + node_id: string + avatar_url: string + gravatar_id: string + url: string + html_url: string + followers_url: string + following_url: string + gists_url: string + starred_url: string + subscriptions_url: string + organizations_url: string + repos_url: string + events_url: string + received_events_url: string + type: string + user_view_type: string + site_admin: boolean +} +interface AssetsItem { + url: string + id: number + node_id: string + name: string + label: string | null + uploader: Uploader + content_type: string + state: string + size: number + download_count: number + created_at: string + updated_at: string + browser_download_url: string +} +interface Uploader { + login: string + id: number + node_id: string + avatar_url: string + gravatar_id: string + url: string + html_url: string + followers_url: string + following_url: string + gists_url: string + starred_url: string + subscriptions_url: string + organizations_url: string + repos_url: string + events_url: string + received_events_url: string + type: string + user_view_type: string + site_admin: boolean +} +interface Reactions { + url: string + total_count: number + "+1": number + "-1": number + laugh: number + hooray: number + confused: number + heart: number + rocket: number + eyes: number +} diff --git a/apps/main/src/updater/utils.ts b/apps/main/src/updater/utils.ts index 88d0b0c7f8..99dc8caf74 100644 --- a/apps/main/src/updater/utils.ts +++ b/apps/main/src/updater/utils.ts @@ -2,6 +2,7 @@ import fs from "node:fs" import path from "node:path" import { app } from "electron" +import { major, minor } from "semver" let _isSquirrelBuild: boolean | null = null export function isSquirrelBuild() { @@ -16,3 +17,39 @@ export function isSquirrelBuild() { return _isSquirrelBuild } + +// The following scenario only requires updating the renderer, so the app update is skipped: +// In x.y.z, the update of z will only trigger renderer hotfix, while the update of y requires updating the entire app. +// The hotfix version of x.y.z-beta.0 adds a suffix number. It triggers renderer update. If the main code is modified and the entire app update needs to be triggered, the hotfix version adds a suffix like x.y.z-beta.0.app. +// For subsequent minor versions that require updating the main code, the suffix .app needs to be added. + +export const shouldUpdateApp = (currentVersion: string, nextVersion: string) => { + if (nextVersion.includes("app")) { + return true + } + // x.y.z 's y or x not equal, need update app + const [x1, x2] = [safeMajor(currentVersion), safeMajor(nextVersion)] + const [y1, y2] = [safeMinor(currentVersion), safeMinor(nextVersion)] + + // Here, it is not determined whether it is a problem of version number downgrade; the updater will handle it automatically. + if (x1 !== x2 || y1 !== y2) { + return true + } + + return false +} + +const safeMajor = (version: string) => { + try { + return major(version) + } catch { + return "0.0.0" + } +} +const safeMinor = (version: string) => { + try { + return minor(version) + } catch { + return "0.0.0" + } +} diff --git a/apps/main/src/window.ts b/apps/main/src/window.ts index ce551da7e9..105895d9d0 100644 --- a/apps/main/src/window.ts +++ b/apps/main/src/window.ts @@ -14,8 +14,10 @@ import { getIconPath } from "./helper" import { t } from "./lib/i18n" import { store } from "./lib/store" import { getTrayConfig } from "./lib/tray" +import { refreshBound } from "./lib/utils" import { logger } from "./logger" import { cancelPollingUpdateUnreadCount, pollingUpdateUnreadCount } from "./tipc/dock" +import { loadDynamicRenderEntry } from "./updater/hot-updater" const windows = { settingWindow: null as BrowserWindow | null, @@ -87,18 +89,10 @@ export function createWindow( }) window.on("leave-html-full-screen", () => { - function refreshBound(timeout = 0) { - setTimeout(() => { - // FIXME: workaround for theme bug in full screen mode - const size = window?.getSize() - window?.setSize(size[0] + 1, size[1] + 1) - window?.setSize(size[0], size[1]) - }, timeout) - } // To solve the vibrancy losing issue when leaving full screen mode // @see https://github.com/toeverything/AFFiNE/blob/280e24934a27557529479a70ab38c4f5fc65cb00/packages/frontend/electron/src/main/windows-manager/main-window.ts:L157 - refreshBound() - refreshBound(1000) + refreshBound(window) + refreshBound(window, 1000) }) window.on("ready-to-show", () => { @@ -149,11 +143,15 @@ export function createWindow( logger.log(process.env["ELECTRON_RENDERER_URL"] + (options?.extraPath || "")) } else { - const openPath = path.resolve(__dirname, "../renderer/index.html") - window.loadFile(openPath, { + // Production entry + const dynamicRenderEntry = loadDynamicRenderEntry() + logger.info("load dynamic render entry", dynamicRenderEntry) + const appLoadEntry = dynamicRenderEntry || path.resolve(__dirname, "../renderer/index.html") + + window.loadFile(appLoadEntry, { hash: options?.extraPath, }) - logger.log(openPath, { + logger.log(appLoadEntry, { hash: options?.extraPath, }) } @@ -282,7 +280,7 @@ export const createMainWindow = () => { const caller = callWindowExpose(window) const settings = await caller.getUISettings() - if (settings.showDockBadge) { + if (settings?.showDockBadge) { pollingUpdateUnreadCount() } }) diff --git a/apps/renderer/src/atoms/updater.ts b/apps/renderer/src/atoms/updater.ts index 548d3cfa1c..70c566f2c7 100644 --- a/apps/renderer/src/atoms/updater.ts +++ b/apps/renderer/src/atoms/updater.ts @@ -1,10 +1,12 @@ -import { getStorageNS } from "@follow/utils/ns" -import { atomWithStorage } from "jotai/utils" +import { atom } from "jotai" import { createAtomHooks } from "~/lib/jotai" -export const [, , useUpdaterStatus, , , setUpdaterStatus] = createAtomHooks( - atomWithStorage(getStorageNS("updater"), false, undefined, { - getOnInit: true, - }), +export type UpdaterStatus = "ready" +export type UpdaterStatusAtom = { + type: "app" | "renderer" + status: UpdaterStatus +} | null +export const [, , useUpdaterStatus, , getUpdaterStatus, setUpdaterStatus] = createAtomHooks( + atom(null as UpdaterStatusAtom), ) diff --git a/apps/renderer/src/initialize/sentry.ts b/apps/renderer/src/initialize/sentry.ts index de594afac0..82702e8d5e 100644 --- a/apps/renderer/src/initialize/sentry.ts +++ b/apps/renderer/src/initialize/sentry.ts @@ -6,6 +6,7 @@ import { useEffect } from "react" import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from "react-router" import { whoami } from "~/atoms/user" +import { isElectronBuild } from "~/constants" import { SentryConfig } from "./sentry.config" @@ -48,5 +49,6 @@ export const initSentry = async () => { } Sentry.setTag("session_trace_id", appSessionTraceId) - Sentry.setTag("appVersion", version) + Sentry.setTag("app_version", version) + Sentry.setTag("build", isElectronBuild ? "electron" : "web") } diff --git a/apps/renderer/src/modules/feed-column/auto-updater.tsx b/apps/renderer/src/modules/feed-column/auto-updater.tsx index f140bb1501..d414df73f9 100644 --- a/apps/renderer/src/modules/feed-column/auto-updater.tsx +++ b/apps/renderer/src/modules/feed-column/auto-updater.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect } from "react" import { useTranslation } from "react-i18next" import { useAudioPlayerAtomSelector } from "~/atoms/player" -import { setUpdaterStatus, useUpdaterStatus } from "~/atoms/updater" +import { getUpdaterStatus, setUpdaterStatus, useUpdaterStatus } from "~/atoms/updater" import { softBouncePreset } from "~/components/ui/constants/spring" import { tipcClient } from "~/lib/client" import { handlers } from "~/tipc" @@ -15,16 +15,31 @@ export const AutoUpdater = () => { useEffect(() => { const unlisten = handlers?.updateDownloaded.listen(() => { - setUpdaterStatus(true) + setUpdaterStatus({ + type: "app", + status: "ready", + }) }) return unlisten }, []) const handleClick = useCallback(() => { - setUpdaterStatus(false) - window.analytics?.capture("update_restart") - - tipcClient?.quitAndInstall() + const status = getUpdaterStatus() + if (!status) return + window.analytics?.capture("update_restart", { + type: status.type, + }) + switch (status.type) { + case "app": { + tipcClient?.quitAndInstall() + break + } + case "renderer": { + tipcClient?.rendererUpdateReload() + break + } + } + setUpdaterStatus(null) }, []) const playerIsShow = useAudioPlayerAtomSelector((s) => s.show) @@ -68,7 +83,9 @@ export const AutoUpdater = () => { } />
{t("notify.update_info", { app_name: APP_NAME })}
-
{t("notify.update_info_1")}
+
+ {updaterStatus.type === "app" ? t("notify.update_info_1") : t("notify.update_info_2")} +
) } diff --git a/apps/renderer/src/modules/settings/tabs/about.tsx b/apps/renderer/src/modules/settings/tabs/about.tsx index 671203ccb1..12db985624 100644 --- a/apps/renderer/src/modules/settings/tabs/about.tsx +++ b/apps/renderer/src/modules/settings/tabs/about.tsx @@ -4,15 +4,21 @@ import { styledButtonVariant } from "@follow/components/ui/button/variants.js" import { Divider } from "@follow/components/ui/divider/index.js" import { getCurrentEnvironment } from "@follow/utils/environment" import { license, repository } from "@pkg" +import { useQuery } from "@tanstack/react-query" import { Trans, useTranslation } from "react-i18next" import { CopyButton } from "~/components/ui/code-highlighter" import { SocialMediaLinks } from "~/constants/social" +import { tipcClient } from "~/lib/client" import { getNewIssueUrl } from "~/lib/issues" export const SettingAbout = () => { const { t } = useTranslation("settings") const currentEnvironment = getCurrentEnvironment().join("\n") + const { data: renderVersion } = useQuery({ + queryKey: ["renderVersion"], + queryFn: () => tipcClient?.getRenderVersion() || "", + }) return (
@@ -24,10 +30,19 @@ export const SettingAbout = () => {
{APP_NAME} {!import.meta.env.PROD ? `(${import.meta.env.MODE})` : ""}
-
- {APP_VERSION} +
+ app: {APP_VERSION} + {renderVersion && ( + + renderer: {renderVersion} + + )}
diff --git a/apps/renderer/src/providers/extension-expose-provider.tsx b/apps/renderer/src/providers/extension-expose-provider.tsx index 93912648a9..732299456a 100644 --- a/apps/renderer/src/providers/extension-expose-provider.tsx +++ b/apps/renderer/src/providers/extension-expose-provider.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner" import { getGeneralSettings } from "~/atoms/settings/general" import { getUISettings, useToggleZenMode } from "~/atoms/settings/ui" +import { setUpdaterStatus } from "~/atoms/updater" import { useDialog, useModalStack } from "~/components/ui/modal/stacked/hooks" import { useDiscoverRSSHubRouteModal } from "~/hooks/biz/useDiscoverRSSHubRoute" import { useFollow } from "~/hooks/biz/useFollow" @@ -40,6 +41,12 @@ export const ExtensionExposeProvider = () => { clearIfLoginOtherAccount(newUserId: string) { clearDataIfLoginOtherAccount(newUserId) }, + readyToUpdate() { + setUpdaterStatus({ + type: "renderer", + status: "ready", + }) + }, }) }, []) useEffect(() => { diff --git a/apps/server/client/initialize/sentry.ts b/apps/server/client/initialize/sentry.ts index 9b108ebad3..2919bfe231 100644 --- a/apps/server/client/initialize/sentry.ts +++ b/apps/server/client/initialize/sentry.ts @@ -18,7 +18,8 @@ export const initSentry = async () => { ...SentryConfig, }) - Sentry.setTag("appVersion", APP_VERSION) + Sentry.setTag("app_version", APP_VERSION) + Sentry.setTag("build", "external-pages") } const ERROR_PATTERNS = [ diff --git a/configs/vite.electron-render.config.ts b/configs/vite.electron-render.config.ts new file mode 100644 index 0000000000..1882b25fa1 --- /dev/null +++ b/configs/vite.electron-render.config.ts @@ -0,0 +1,32 @@ +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import type { UserConfig } from "vite" + +import { createPlatformSpecificImportPlugin } from "../plugins/vite/specific-import" +import { viteRenderBaseConfig } from "./vite.render.config" + +const root = resolve(fileURLToPath(dirname(import.meta.url)), "..") + +export default { + ...viteRenderBaseConfig, + + plugins: [...viteRenderBaseConfig.plugins, createPlatformSpecificImportPlugin(true)], + + root: resolve(root, "apps/renderer"), + build: { + outDir: resolve(root, "dist/renderer"), + sourcemap: !!process.env.CI, + target: "esnext", + rollupOptions: { + input: { + main: resolve(root, "apps/renderer/index.html"), + }, + }, + minify: true, + }, + define: { + ...viteRenderBaseConfig.define, + ELECTRON: "true", + }, +} satisfies UserConfig diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2350d75073..895a73ce65 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -5,6 +5,7 @@ import { defineConfig } from "electron-vite" import { viteRenderBaseConfig } from "./configs/vite.render.config" import { cleanupUnnecessaryFilesPlugin } from "./plugins/vite/cleanup" import { createPlatformSpecificImportPlugin } from "./plugins/vite/specific-import" +import { getGitHash } from "./scripts/lib" export default defineConfig({ main: { @@ -24,6 +25,7 @@ export default defineConfig({ }, define: { ELECTRON: "true", + GIT_COMMIT_HASH: JSON.stringify(getGitHash()), }, }, preload: { @@ -43,6 +45,19 @@ export default defineConfig({ renderer: { ...viteRenderBaseConfig, + root: "apps/renderer", + build: { + outDir: "dist/renderer", + sourcemap: !!process.env.CI, + target: "esnext", + rollupOptions: { + input: { + main: resolve("./apps/renderer/index.html"), + }, + }, + minify: true, + }, + plugins: [ ...viteRenderBaseConfig.plugins, createPlatformSpecificImportPlugin(true), @@ -61,18 +76,6 @@ export default defineConfig({ ]), ], - root: "apps/renderer", - build: { - outDir: "dist/renderer", - sourcemap: !!process.env.CI, - target: "esnext", - rollupOptions: { - input: { - main: resolve("./apps/renderer/index.html"), - }, - }, - minify: true, - }, define: { ...viteRenderBaseConfig.define, ELECTRON: "true", diff --git a/locales/app/en.json b/locales/app/en.json index 329ffce53b..ca4668c429 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -263,6 +263,7 @@ "notify.unfollow_feed_many": "All selected feeds have been unfollowed.", "notify.update_info": "{{app_name}} is ready to update!", "notify.update_info_1": "Click to restart", + "notify.update_info_2": "Click to reload page", "player.back_10s": "Back 10s", "player.close": "Close", "player.download": "Download", diff --git a/package.json b/package.json index 2fa8555a67..8950f754bb 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "analyze:web": "analyzer=1 vite build", "build": "electron-vite build && electron-forge make", "build:macos": "electron-vite build && electron-forge make --arch=x64 --platform=darwin && electron-forge make --arch=arm64 --platform=darwin && tsx scripts/merge-yml.ts", + "build:render": "vite build -c vite.config.electron-render.ts", "build:web": "rm -rf out/web && cross-env WEB_BUILD=1 vite build", "bump": "vv", "dedupe:locales": "eslint --fix locales/**/*.json", @@ -37,7 +38,8 @@ "start": "electron-vite preview", "sync:ab": "tsx scripts/pull-ab-flags.ts", "test": "CI=1 pnpm --recursive run test", - "typecheck": "turbo typecheck" + "typecheck": "turbo typecheck", + "update:main-hash": "tsx plugins/vite/generate-main-hash.ts" }, "devDependencies": { "@babel/generator": "7.26.2", @@ -65,6 +67,7 @@ "@tailwindcss/container-queries": "0.1.1", "@tailwindcss/typography": "0.5.15", "@types/html-minifier-terser": "7.0.2", + "@types/js-yaml": "4.0.9", "@types/node": "^22.9.3", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", @@ -104,6 +107,7 @@ "tailwindcss-animate": "1.0.7", "tailwindcss-motion": "0.4.3-beta", "tailwindcss-safe-area": "0.6.0", + "tar": "7.4.3", "tsup": "8.3.5", "tsx": "4.19.2", "turbo": "2.3.1", @@ -149,7 +153,11 @@ "before": [ "git pull --rebase", "tsx scripts/apply-changelog.ts ${NEW_VERSION}", - "git add changelog" + "git add changelog", + "tsx plugins/vite/generate-main-hash.ts", + "eslint --fix package.json", + "prettier --ignore-unknown --write package.json", + "git add package.json" ], "after": [ "gh pr create --title 'chore: Release v${NEW_VERSION}' --body 'v${NEW_VERSION}' --base main --head dev" @@ -161,5 +169,6 @@ "dev" ] }, - "productName": "Follow" + "productName": "Follow", + "mainHash": "1" } diff --git a/packages/shared/src/bridge.ts b/packages/shared/src/bridge.ts index b968f32a53..15848af7e5 100644 --- a/packages/shared/src/bridge.ts +++ b/packages/shared/src/bridge.ts @@ -45,6 +45,8 @@ interface RenderGlobalContext { /// Utils toast: typeof toast + + readyToUpdate: () => void dialog: typeof dialog // URL getWebUrl: () => string diff --git a/plugins/vite/compress.ts b/plugins/vite/compress.ts new file mode 100644 index 0000000000..4690f0ee8c --- /dev/null +++ b/plugins/vite/compress.ts @@ -0,0 +1,66 @@ +import { execSync } from "node:child_process" +import { createHash } from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" + +import * as tar from "tar" +import type { Plugin } from "vite" + +import { calculateMainHash } from "./generate-main-hash" + +async function compressDirectory(sourceDir: string, outputFile: string) { + await tar.c( + { + gzip: true, + file: outputFile, + cwd: sourceDir, + }, + ["renderer"], + ) +} + +function compressAndFingerprintPlugin(outDir: string): Plugin { + return { + name: "compress-and-fingerprint", + apply: "build", + async closeBundle() { + const outputFile = path.join(outDir, "render-asset.tar.gz") + const manifestFile = path.join(outDir, "manifest.yml") + + console.info("Compressing and fingerprinting...") + // Compress the entire output directory + await compressDirectory(outDir, outputFile) + console.info("Compressing and fingerprinting", outDir, "done") + + // Calculate the file hash + const fileBuffer = await fs.readFile(outputFile) + const hashSum = createHash("sha256") + hashSum.update(fileBuffer) + const hex = hashSum.digest("hex") + + // Calculate main hash + const mainHash = await calculateMainHash(path.resolve(process.cwd(), "apps/main")) + + // Get the current git tag version + let version = "unknown" + try { + version = execSync("git describe --tags").toString().trim() + } catch (error) { + console.warn("Could not retrieve git tag version:", error) + } + + // Write the manifest file + const manifestContent = ` +version: ${version.startsWith("v") ? version.slice(1) : version} +hash: ${hex} +mainHash: ${mainHash} +commit: ${execSync("git rev-parse HEAD").toString().trim()} +filename: ${path.basename(outputFile)} +` + console.info("Writing manifest file", manifestContent) + await fs.writeFile(manifestFile, manifestContent.trim()) + }, + } +} + +export default compressAndFingerprintPlugin diff --git a/plugins/vite/generate-main-hash.ts b/plugins/vite/generate-main-hash.ts new file mode 100644 index 0000000000..69a60e8976 --- /dev/null +++ b/plugins/vite/generate-main-hash.ts @@ -0,0 +1,46 @@ +import { createHash } from "node:crypto" +import fs from "node:fs/promises" +import { createRequire } from "node:module" +import path from "node:path" + +const require = createRequire(import.meta.url) +const glob = require("glob") as typeof import("glob") + +export async function calculateMainHash(mainDir: string): Promise { + // Get all TypeScript files in the main directory recursively + const files = glob.sync("**/*.{ts,tsx}", { + cwd: mainDir, + ignore: ["node_modules/**", "dist/**"], + }) + + // Sort files for consistent hash + + files.sort() + files.push("package.json") + + const hashSum = createHash("sha256") + + // Read and update hash for each file + for (const file of files) { + const content = await fs.readFile(path.join(mainDir, file)) + const normalizedContent = content.toString("utf-8").replaceAll("\r\n", "\n") + hashSum.update(Buffer.from(normalizedContent)) + } + + return hashSum.digest("hex") +} + +async function main() { + const hash = await calculateMainHash(path.resolve(process.cwd(), "apps/main")) + + const packageJson = JSON.parse( + await fs.readFile(path.resolve(process.cwd(), "package.json"), "utf-8"), + ) + packageJson.mainHash = hash + await fs.writeFile( + path.resolve(process.cwd(), "package.json"), + JSON.stringify(packageJson, null, 2), + ) +} + +main() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f73d6ccee..bd7d0fb374 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: '@types/html-minifier-terser': specifier: 7.0.2 version: 7.0.2 + '@types/js-yaml': + specifier: 4.0.9 + version: 4.0.9 '@types/node': specifier: ^22.9.3 version: 22.9.3 @@ -236,6 +239,9 @@ importers: tailwindcss-safe-area: specifier: 0.6.0 version: 0.6.0(tailwindcss@3.4.15(ts-node@10.9.1(@types/node@22.9.3)(typescript@5.7.2))) + tar: + specifier: 7.4.3 + version: 7.4.3 tsup: specifier: 8.3.5 version: 8.3.5(jiti@2.3.3)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.7.2)(yaml@2.6.1) @@ -290,6 +296,9 @@ importers: '@mozilla/readability': specifier: ^0.5.0 version: 0.5.0(patch_hash=43niildbdafdxi7qfcwhpkkxwa) + '@openpanel/web': + specifier: 1.0.1 + version: 1.0.1 '@sentry/electron': specifier: 5.7.0 version: 5.7.0 @@ -308,6 +317,9 @@ importers: electron-updater: specifier: ^6.3.9 version: 6.3.9 + es-toolkit: + specifier: 1.26.1 + version: 1.26.1 fast-folder-size: specifier: 2.3.0 version: 2.3.0 @@ -317,6 +329,9 @@ importers: i18next: specifier: ^24.0.0 version: 24.0.0(typescript@5.7.2) + js-yaml: + specifier: 4.1.0 + version: 4.1.0 linkedom: specifier: ^0.18.5 version: 0.18.5 @@ -326,16 +341,25 @@ importers: msedge-tts: specifier: 1.3.4 version: 1.3.4(bufferutil@4.0.8)(utf-8-validate@6.0.4) + node-machine-id: + specifier: 1.1.12 + version: 1.1.12 ofetch: specifier: 1.4.1 version: 1.4.1 semver: specifier: 7.6.3 version: 7.6.3 + tar: + specifier: 7.4.3 + version: 7.4.3 vscode-languagedetection: specifier: npm:@vscode/vscode-languagedetection@^1.0.22 version: '@vscode/vscode-languagedetection@1.0.22' devDependencies: + '@types/js-yaml': + specifier: 4.0.9 + version: 4.0.9 '@types/node': specifier: ^22.9.3 version: 22.9.3 @@ -2840,6 +2864,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -4471,6 +4499,9 @@ packages: '@types/http-cache-semantics@4.0.4': resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -5297,6 +5328,10 @@ packages: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -6275,6 +6310,9 @@ packages: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} engines: {node: '>= 0.4'} + es-toolkit@1.26.1: + resolution: {integrity: sha512-E3H14lHWk8JpupVpIRA1gfNF4r953abHTFW+X1Rp7zl7eG37ksuthfEA4FinyVF/Y807vzzfQS1nubeZk2LTVA==} + es-toolkit@1.27.0: resolution: {integrity: sha512-ETSFA+ZJArcuSCpzD2TjAy6UHpx4E4uqFsoDg9F/nTLogrLmVVZQ+zNxco5h7cWnA1nNak07IXsLcaSMih+ZPQ==} @@ -8496,6 +8534,10 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} + minizlib@3.0.1: + resolution: {integrity: sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==} + engines: {node: '>= 18'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -8508,6 +8550,11 @@ packages: engines: {node: '>=10'} hasBin: true + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + mlly@1.7.2: resolution: {integrity: sha512-tN3dvVHYVz4DhSXinXIk7u9syPYaJvio118uomkovAtWBT+RdbP6Lfh/5Lvo519YMmwBafwlh20IPTXIStscpA==} @@ -8618,6 +8665,9 @@ packages: engines: {node: ^12.13 || ^14.13 || >=16} hasBin: true + node-machine-id@1.1.12: + resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==} + node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} @@ -9872,6 +9922,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + rimraf@6.0.1: resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} engines: {node: 20 || >=22} @@ -10404,6 +10458,10 @@ packages: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + tar@7.4.3: + resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} + engines: {node: '>=18'} + temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} @@ -11262,6 +11320,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.5.1: resolution: {integrity: sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==} engines: {node: '>= 14'} @@ -13972,6 +14034,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -15812,6 +15878,8 @@ snapshots: '@types/http-cache-semantics@4.0.4': {} + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/katex@0.16.7': {} @@ -16979,6 +17047,8 @@ snapshots: chownr@2.0.0: {} + chownr@3.0.0: {} + chrome-trace-event@1.0.4: {} chromium-pickle-js@0.2.0: {} @@ -18033,6 +18103,8 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 + es-toolkit@1.26.1: {} + es-toolkit@1.27.0: {} es6-error@4.1.1: @@ -20867,6 +20939,11 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 + minizlib@3.0.1: + dependencies: + minipass: 7.1.2 + rimraf: 5.0.10 + mkdirp-classic@0.5.3: {} mkdirp@0.5.6: @@ -20875,6 +20952,8 @@ snapshots: mkdirp@1.0.4: {} + mkdirp@3.0.1: {} + mlly@1.7.2: dependencies: acorn: 8.14.0 @@ -21003,6 +21082,8 @@ snapshots: - bluebird - supports-color + node-machine-id@1.1.12: {} + node-releases@2.0.18: {} nopt@5.0.0: @@ -22285,6 +22366,10 @@ snapshots: dependencies: glob: 7.2.3 + rimraf@5.0.10: + dependencies: + glob: 10.4.5 + rimraf@6.0.1: dependencies: glob: 11.0.0 @@ -22956,6 +23041,15 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 + tar@7.4.3: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.0.1 + mkdirp: 3.0.1 + yallist: 5.0.0 + temp-dir@2.0.0: {} temp-file@3.4.0: @@ -23920,6 +24014,8 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: {} + yaml@2.5.1: {} yaml@2.6.1: {} diff --git a/tsconfig.json b/tsconfig.json index f09abbbf23..1eb64c31c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,6 @@ { - "extends": "@electron-toolkit/tsconfig/tsconfig.node.json" + "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", + "compilerOptions": { + "target": "ES2022" + } } diff --git a/vite.config.electron-render.ts b/vite.config.electron-render.ts new file mode 100644 index 0000000000..851093c88d --- /dev/null +++ b/vite.config.electron-render.ts @@ -0,0 +1,12 @@ +import { resolve } from "node:path" + +import { defineConfig } from "vite" + +import config from "./configs/vite.electron-render.config" +import compressAndFingerprintPlugin from "./plugins/vite/compress" + +export default defineConfig({ + ...config, + base: "./", + plugins: [...config.plugins, compressAndFingerprintPlugin(resolve(import.meta.dirname, "dist"))], +})