Skip to content

Commit dee294d

Browse files
committed
feat(app): support cache limit and clean cache
Signed-off-by: Innei <tukon479@gmail.com>
1 parent 1d0eca1 commit dee294d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+623
-127
lines changed

apps/main/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"electron-log": "5.2.0",
3535
"electron-squirrel-startup": "1.0.1",
3636
"electron-updater": "^6.3.9",
37+
"fast-folder-size": "2.3.0",
3738
"font-list": "1.5.1",
3839
"i18next": "^23.16.4",
3940
"linkedom": "^0.18.5",

apps/main/src/init.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { app, nativeTheme, Notification, shell } from "electron"
99
import contextMenu from "electron-context-menu"
1010

1111
import { getIconPath } from "./helper"
12+
import { clearCacheCronJob } from "./lib/cleaner"
1213
import { t } from "./lib/i18n"
1314
import { store } from "./lib/store"
1415
import { updateNotificationsToken } from "./lib/user"
@@ -59,6 +60,7 @@ export const initializeAppStage1 = () => {
5960
registerMenuAndContextMenu()
6061

6162
registerPushNotifications()
63+
clearCacheCronJob()
6264
}
6365

6466
let contextMenuDisposer: () => void

apps/main/src/lib/cleaner.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import { statSync } from "node:fs"
2+
import fsp from "node:fs/promises"
3+
import { createRequire } from "node:module"
4+
import path from "node:path"
5+
import { promisify } from "node:util"
6+
17
import { callWindowExpose } from "@follow/shared/bridge"
2-
import { dialog } from "electron"
8+
import { app, dialog } from "electron"
39

10+
import { logger } from "~/logger"
411
import { getMainWindow } from "~/window"
512

613
import { t } from "./i18n"
14+
import { store, StoreKey } from "./store"
15+
16+
const require = createRequire(import.meta.url)
17+
const fastFolderSize = require("fast-folder-size") as any as typeof import("fast-folder-size")
718

819
export const clearAllDataAndConfirm = async () => {
920
const win = getMainWindow()
@@ -53,3 +64,75 @@ export const clearAllData = async () => {
5364
caller.toast.error(`Error resetting app data: ${error.message}`)
5465
}
5566
}
67+
const fastFolderSizeAsync = promisify(fastFolderSize)
68+
export const getCacheSize = async () => {
69+
const cachePath = path.join(app.getPath("userData"), "cache")
70+
71+
// Size is in bytes
72+
const sizeInBytes = await fastFolderSizeAsync(cachePath)
73+
return sizeInBytes || 0
74+
}
75+
76+
const getCachedFilesRecursive = async (dir: string, result: string[] = []) => {
77+
const files = await fsp.readdir(dir)
78+
79+
for (const file of files) {
80+
const filePath = path.join(dir, file)
81+
const stat = await fsp.stat(filePath)
82+
if (stat.isDirectory()) {
83+
const files = await getCachedFilesRecursive(filePath)
84+
result.push(...files)
85+
} else {
86+
result.push(filePath)
87+
}
88+
}
89+
return result
90+
}
91+
92+
let timer: any = null
93+
export const clearCacheCronJob = () => {
94+
if (timer) {
95+
timer = clearInterval(timer)
96+
}
97+
timer = setInterval(
98+
async () => {
99+
const hasLimit = store.get(StoreKey.CacheSizeLimit)
100+
101+
if (!hasLimit) {
102+
return
103+
}
104+
105+
const cacheSize = await getCacheSize()
106+
107+
const limitByteSize = hasLimit * 1024 * 1024
108+
if (cacheSize > limitByteSize) {
109+
const shouldCleanSize = cacheSize - limitByteSize - 1024 * 1024 * 50 // 50MB
110+
111+
const cachePath = path.join(app.getPath("userData"), "cache")
112+
const files = await getCachedFilesRecursive(cachePath)
113+
// Sort by last modified
114+
files.sort((a, b) => {
115+
const aStat = statSync(a)
116+
const bStat = statSync(b)
117+
return bStat.mtime.getTime() - aStat.mtime.getTime()
118+
})
119+
120+
let cleanedSize = 0
121+
for (const file of files) {
122+
const fileSize = statSync(file).size
123+
cleanedSize += fileSize
124+
if (cleanedSize >= shouldCleanSize) {
125+
logger.info(`Cleaned ${cleanedSize} bytes cache`)
126+
break
127+
}
128+
}
129+
}
130+
},
131+
10 * 60 * 1000,
132+
) // 10 min
133+
134+
return () => {
135+
if (!timer) return
136+
timer = clearInterval(timer)
137+
}
138+
}

apps/main/src/lib/store.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ const createOrGetDb = () => {
1515
}
1616
return db
1717
}
18+
19+
export enum StoreKey {
20+
CacheSizeLimit = "cacheSizeLimit",
21+
}
22+
1823
export const store = {
1924
get: (key: string) => {
2025
const db = createOrGetDb()
@@ -26,4 +31,9 @@ export const store = {
2631
db.data[key] = value
2732
db.write()
2833
},
34+
delete: (key: string) => {
35+
const db = createOrGetDb()
36+
delete db.data[key]
37+
db.write()
38+
},
2939
}

apps/main/src/tipc/app.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import type { BrowserWindow } from "electron"
88
import { app, clipboard, dialog, screen } from "electron"
99

1010
import { registerMenuAndContextMenu } from "~/init"
11-
import { clearAllData } from "~/lib/cleaner"
11+
import { clearAllData, getCacheSize } from "~/lib/cleaner"
12+
import { store, StoreKey } from "~/lib/store"
13+
import { logger } from "~/logger"
1214

1315
import { isWindows11 } from "../env"
1416
import { downloadFile } from "../lib/download"
@@ -267,6 +269,27 @@ ${content}
267269
return { success: false, error: errorMessage }
268270
}
269271
}),
272+
273+
getCacheSize: t.procedure.action(async () => {
274+
return getCacheSize()
275+
}),
276+
getCacheLimit: t.procedure.action(async () => {
277+
return store.get(StoreKey.CacheSizeLimit)
278+
}),
279+
280+
clearCache: t.procedure.action(async () => {
281+
const cachePath = path.join(app.getPath("userData"), "cache")
282+
await fsp.rm(cachePath, { recursive: true, force: true })
283+
}),
284+
285+
limitCacheSize: t.procedure.input<number>().action(async ({ input }) => {
286+
logger.info("set limitCacheSize", input)
287+
if (input === 0) {
288+
store.delete(StoreKey.CacheSizeLimit)
289+
} else {
290+
store.set(StoreKey.CacheSizeLimit, input)
291+
}
292+
}),
270293
}
271294

272295
interface Sender extends Electron.WebContents {

apps/renderer/src/modules/settings/settings-glob.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ function getSettings() {
55

66
const settings = [] as {
77
name: I18nKeysForSettings
8-
iconName: string
8+
icon: string | React.ReactNode
99
path: string
1010
Component: () => JSX.Element
1111
priority: number
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { CarbonInfinitySymbol } from "@follow/components/icons/infinify.jsx"
2+
import { Button } from "@follow/components/ui/button/index.js"
3+
import { Label } from "@follow/components/ui/label/index.jsx"
4+
import { Slider } from "@follow/components/ui/slider/index.js"
5+
import { env } from "@follow/shared/env"
6+
import { useQuery } from "@tanstack/react-query"
7+
import { useEffect } from "react"
8+
import { useTranslation } from "react-i18next"
9+
10+
import { setGeneralSetting, useGeneralSettingValue } from "~/atoms/settings/general"
11+
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
12+
import { exportDB } from "~/database"
13+
import { initAnalytics } from "~/initialize/analytics"
14+
import { tipcClient } from "~/lib/client"
15+
import { queryClient } from "~/lib/query-client"
16+
import { clearLocalPersistStoreData } from "~/store/utils/clear"
17+
18+
import { SettingDescription } from "../control"
19+
import { createSetting } from "../helper/builder"
20+
import { SettingItemGroup } from "../section"
21+
22+
const { defineSettingItem, SettingBuilder } = createSetting(
23+
useGeneralSettingValue,
24+
setGeneralSetting,
25+
)
26+
export const SettingDataControl = () => {
27+
const { t } = useTranslation("settings")
28+
useEffect(() => {
29+
tipcClient?.getLoginItemSettings().then((settings) => {
30+
setGeneralSetting("appLaunchOnStartup", settings.openAtLogin)
31+
})
32+
}, [])
33+
34+
const { present } = useModalStack()
35+
36+
return (
37+
<div className="mt-4">
38+
<SettingBuilder
39+
settings={[
40+
{
41+
type: "title",
42+
value: t("general.privacy"),
43+
},
44+
defineSettingItem("sendAnonymousData", {
45+
label: t("general.send_anonymous_data.label"),
46+
description: t("general.send_anonymous_data.description"),
47+
onChange(value) {
48+
setGeneralSetting("sendAnonymousData", value)
49+
if (value) {
50+
initAnalytics()
51+
} else {
52+
window.analytics?.reset()
53+
delete window.analytics
54+
}
55+
},
56+
}),
57+
58+
{
59+
type: "title",
60+
value: t("general.data"),
61+
},
62+
defineSettingItem("dataPersist", {
63+
label: t("general.data_persist.label"),
64+
description: t("general.data_persist.description"),
65+
}),
66+
67+
{
68+
label: t("general.rebuild_database.label"),
69+
action: () => {
70+
present({
71+
title: t("general.rebuild_database.title"),
72+
clickOutsideToDismiss: true,
73+
content: () => (
74+
<div className="text-sm">
75+
<p>{t("general.rebuild_database.warning.line1")}</p>
76+
<p>{t("general.rebuild_database.warning.line2")}</p>
77+
<div className="mt-4 flex justify-end">
78+
<Button
79+
className="bg-red-500 px-3 text-white"
80+
onClick={async () => {
81+
await clearLocalPersistStoreData()
82+
window.location.reload()
83+
}}
84+
>
85+
{t("ok", { ns: "common" })}
86+
</Button>
87+
</div>
88+
</div>
89+
),
90+
})
91+
},
92+
description: t("general.rebuild_database.description"),
93+
buttonText: t("general.rebuild_database.button"),
94+
},
95+
{
96+
label: t("general.export_database.label"),
97+
description: t("general.export_database.description"),
98+
buttonText: t("general.export_database.button"),
99+
action: () => {
100+
exportDB()
101+
},
102+
},
103+
{
104+
label: t("general.export.label"),
105+
description: t("general.export.description"),
106+
buttonText: t("general.export.button"),
107+
action: () => {
108+
const link = document.createElement("a")
109+
link.href = `${env.VITE_API_URL}/subscriptions/export`
110+
link.download = "follow.opml"
111+
link.click()
112+
},
113+
},
114+
115+
{
116+
type: "title",
117+
value: t("general.cache"),
118+
},
119+
AppCacheLimit,
120+
{
121+
label: t("data_control.clean_cache.button"),
122+
description: t("data_control.clean_cache.description"),
123+
buttonText: t("data_control.clean_cache.button"),
124+
action: async () => {
125+
await tipcClient?.clearCache()
126+
queryClient.invalidateQueries({ queryKey: ["app", "cache", "size"] })
127+
},
128+
},
129+
]}
130+
/>
131+
</div>
132+
)
133+
}
134+
const AppCacheLimit = () => {
135+
const { t } = useTranslation("settings")
136+
const { data: cacheSize, isLoading: isLoadingCacheSize } = useQuery({
137+
queryKey: ["app", "cache", "size"],
138+
queryFn: async () => {
139+
const byteSize = (await tipcClient?.getCacheSize()) ?? 0
140+
return Math.round(byteSize / 1024 / 1024)
141+
},
142+
})
143+
const {
144+
data: cacheLimit,
145+
isLoading: isLoadingCacheLimit,
146+
refetch: refetchCacheLimit,
147+
} = useQuery({
148+
queryKey: ["app", "cache", "limit"],
149+
queryFn: async () => {
150+
const size = (await tipcClient?.getCacheLimit()) ?? 0
151+
return size
152+
},
153+
})
154+
155+
const onChange = (value: number[]) => {
156+
tipcClient?.limitCacheSize(value[0])
157+
refetchCacheLimit()
158+
}
159+
160+
if (isLoadingCacheSize || isLoadingCacheLimit) return null
161+
162+
const InfinitySymbol = <CarbonInfinitySymbol />
163+
return (
164+
<SettingItemGroup>
165+
<div className={"mb-3 flex items-center justify-between gap-4"}>
166+
<Label className="center flex">
167+
{t("data_control.app_cache_limit.label")}
168+
169+
<span className="center ml-2 flex shrink-0 gap-1 text-xs text-gray-500">
170+
<span>({cacheSize}M</span> /{" "}
171+
<span className="center flex shrink-0">
172+
{cacheLimit ? `${cacheLimit}M` : InfinitySymbol})
173+
</span>
174+
</span>
175+
</Label>
176+
177+
<div className="relative flex w-1/5 flex-col gap-1">
178+
<Slider
179+
min={0}
180+
max={500}
181+
step={100}
182+
defaultValue={[cacheLimit ?? 0]}
183+
onValueCommit={onChange}
184+
/>
185+
<div className="absolute bottom-[-1.5em] text-base opacity-50">{InfinitySymbol}</div>
186+
<div className="absolute bottom-[-1.5em] right-0 text-xs opacity-50">500M</div>
187+
</div>
188+
</div>
189+
<SettingDescription>{t("data_control.app_cache_limit.description")}</SettingDescription>
190+
</SettingItemGroup>
191+
)
192+
}

0 commit comments

Comments
 (0)