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 = () => { } />