feat(infra): electron app can hot update renderer layer (#1209)

* ci

Signed-off-by: Innei <i@innei.in>

* feat: hotupdate renderer impl

* fix: skip dev

Signed-off-by: Innei <i@innei.in>

* update logci

Signed-off-by: Innei <i@innei.in>

* update logci

Signed-off-by: Innei <i@innei.in>

* feat: tracker

Signed-off-by: Innei <tukon479@gmail.com>

* log

Signed-off-by: Innei <tukon479@gmail.com>

* fix: hash

Signed-off-by: Innei <tukon479@gmail.com>

* fix: build

Signed-off-by: Innei <tukon479@gmail.com>

* fix: deps

Signed-off-by: Innei <tukon479@gmail.com>

* Update nightly.yml

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update minimunm

Signed-off-by: Innei <tukon479@gmail.com>

* calc main hash

Signed-off-by: Innei <tukon479@gmail.com>

* add main Hash logic

Signed-off-by: Innei <tukon479@gmail.com>

* remove

Signed-off-by: Innei <tukon479@gmail.com>

* fix

Signed-off-by: Innei <tukon479@gmail.com>

* log

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* test

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* fix: update

Signed-off-by: Innei <tukon479@gmail.com>

* debug

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* fix

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* fix: only one

Signed-off-by: Innei <tukon479@gmail.com>

* try fix cookie

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* fix cookie

Signed-off-by: Innei <tukon479@gmail.com>

* fix: domain

Signed-off-by: Innei <tukon479@gmail.com>

* fix

Signed-off-by: Innei <tukon479@gmail.com>

* fix: remove test code

Signed-off-by: Innei <tukon479@gmail.com>

* fix

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <i@innei.in>
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2024-11-25 21:49:20 +08:00 committed by GitHub
parent 59709f0200
commit ca4751acd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1025 additions and 95 deletions

75
.github/workflows/build-render.yml vendored Normal file
View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -1,2 +1,7 @@
import "../../types/vite"
import "../../types/authjs"
declare global {
const GIT_COMMIT_HASH: string
}
export {}

View File

@ -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",

View File

@ -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"

View File

@ -0,0 +1,3 @@
import { machineIdSync } from "node-machine-id"
export const DEVICE_ID = machineIdSync()

View File

@ -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({

View File

@ -58,7 +58,6 @@ export const initializeAppStage1 = () => {
// code. You can also put them in separate files and require them here.
registerMenuAndContextMenu()
registerPushNotifications()
clearCacheCronJob()
checkAndCleanCodeCache()

20
apps/main/src/lib/op.ts Normal file
View File

@ -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",
})

View File

@ -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)
}

View File

@ -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()
},
},
],

View File

@ -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")
}

View File

@ -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()
}),

View File

@ -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 })
}

View File

@ -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,
},
}

View File

@ -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
}

View File

@ -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)
})
}

View File

@ -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
}

View File

@ -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"
}
}

View File

@ -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()
}
})

View File

@ -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),
)

View File

@ -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")
}

View File

@ -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 = () => {
}
/>
<div className="font-medium">{t("notify.update_info", { app_name: APP_NAME })}</div>
<div className="text-xs text-zinc-500">{t("notify.update_info_1")}</div>
<div className="text-xs text-zinc-500">
{updaterStatus.type === "app" ? t("notify.update_info_1") : t("notify.update_info_2")}
</div>
</m.div>
)
}

View File

@ -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 (
<div>
@ -24,10 +30,19 @@ export const SettingAbout = () => {
<div className="text-lg font-bold">
{APP_NAME} {!import.meta.env.PROD ? `(${import.meta.env.MODE})` : ""}
</div>
<div className="flex items-center gap-2">
<span className="rounded bg-muted px-2 py-1 text-xs">{APP_VERSION}</span>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded bg-muted px-2 py-1 text-xs">app: {APP_VERSION}</span>
{renderVersion && (
<span className="rounded bg-muted px-2 py-1 text-xs">
renderer: {renderVersion}
</span>
)}
<CopyButton
value={currentEnvironment}
value={
renderVersion
? `${currentEnvironment}\n**Renderer**: ${renderVersion}`
: currentEnvironment
}
className="border-0 bg-transparent p-1 text-foreground/80 hover:bg-theme-item-hover hover:text-foreground active:bg-theme-item-active [&_i]:size-3"
/>
</div>

View File

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

View File

@ -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 = [

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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"
}

View File

@ -45,6 +45,8 @@ interface RenderGlobalContext {
/// Utils
toast: typeof toast
readyToUpdate: () => void
dialog: typeof dialog
// URL
getWebUrl: () => string

66
plugins/vite/compress.ts Normal file
View File

@ -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

View File

@ -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<string> {
// 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()

View File

@ -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: {}

View File

@ -1,3 +1,6 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json"
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"compilerOptions": {
"target": "ES2022"
}
}

View File

@ -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"))],
})