From 17d49b481faa218c299dc232953a46d28b788468 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:48:12 +0800 Subject: [PATCH 1/7] :recycle: Bazaar adds parameter validation (#17132) --- kernel/api/bazaar.go | 182 ++++++++++++++++++++++++++----------------- kernel/util/net.go | 53 +++++++++++++ 2 files changed, 162 insertions(+), 73 deletions(-) diff --git a/kernel/api/bazaar.go b/kernel/api/bazaar.go index 9d00cb985..26fbc9872 100644 --- a/kernel/api/bazaar.go +++ b/kernel/api/bazaar.go @@ -25,6 +25,14 @@ import ( "github.com/siyuan-note/siyuan/kernel/util" ) +var validPackageTypes = map[string]bool{ + "plugins": true, + "themes": true, + "icons": true, + "templates": true, + "widgets": true, +} + func batchUpdatePackage(c *gin.Context) { ret := gulu.Ret.NewResult() defer c.JSON(http.StatusOK, ret) @@ -34,7 +42,10 @@ func batchUpdatePackage(c *gin.Context) { return } - frontend := arg["frontend"].(string) + var frontend string + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("frontend", true, &frontend)) { + return + } model.BatchUpdateBazaarPackages(frontend) } @@ -47,7 +58,10 @@ func getUpdatedPackage(c *gin.Context) { return } - frontend := arg["frontend"].(string) + var frontend string + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("frontend", true, &frontend)) { + return + } plugins, widgets, icons, themes, templates := model.UpdatedPackages(frontend) ret.Data = map[string]interface{}{ "plugins": plugins, @@ -67,11 +81,21 @@ func getBazaarPackageREADME(c *gin.Context) { return } - repoURL := arg["repoURL"].(string) - repoHash := arg["repoHash"].(string) - packageType := arg["packageType"].(string) + var repoURL, repoHash, pkgType string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("repoURL", true, &repoURL), + util.BindJsonArg("repoHash", true, &repoHash), + util.BindJsonArg("packageType", true, &pkgType), + ) { + return + } + if !validPackageTypes[pkgType] { + ret.Code = -1 + ret.Msg = "Invalid package type" + return + } ret.Data = map[string]interface{}{ - "html": model.GetPackageREADME(repoURL, repoHash, packageType), + "html": model.GetPackageREADME(repoURL, repoHash, pkgType), } } @@ -84,10 +108,12 @@ func getBazaarPlugin(c *gin.Context) { return } - frontend := arg["frontend"].(string) - var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + var frontend, keyword string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("frontend", true, &frontend), + util.BindJsonArg("keyword", false, &keyword), + ) { + return } ret.Data = map[string]interface{}{ @@ -104,10 +130,12 @@ func getInstalledPlugin(c *gin.Context) { return } - frontend := arg["frontend"].(string) - var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + var frontend, keyword string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("frontend", true, &frontend), + util.BindJsonArg("keyword", false, &keyword), + ) { + return } ret.Data = map[string]interface{}{ @@ -124,14 +152,16 @@ func installBazaarPlugin(c *gin.Context) { return } - var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + var frontend, keyword, repoURL, repoHash, packageName string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("frontend", true, &frontend), + util.BindJsonArg("keyword", false, &keyword), + util.BindJsonArg("repoURL", true, &repoURL), + util.BindJsonArg("repoHash", true, &repoHash), + util.BindJsonArg("packageName", true, &packageName), + ) { + return } - - repoURL := arg["repoURL"].(string) - repoHash := arg["repoHash"].(string) - packageName := arg["packageName"].(string) err := model.InstallBazaarPlugin(repoURL, repoHash, packageName) if err != nil { ret.Code = 1 @@ -139,8 +169,6 @@ func installBazaarPlugin(c *gin.Context) { return } - frontend := arg["frontend"].(string) - util.PushMsg(model.Conf.Language(69), 3000) ret.Data = map[string]interface{}{ "packages": model.BazaarPlugins(frontend, keyword), @@ -156,13 +184,14 @@ func uninstallBazaarPlugin(c *gin.Context) { return } - var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + var frontend, keyword, packageName string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("frontend", true, &frontend), + util.BindJsonArg("keyword", false, &keyword), + util.BindJsonArg("packageName", true, &packageName), + ) { + return } - - frontend := arg["frontend"].(string) - packageName := arg["packageName"].(string) err := model.UninstallBazaarPlugin(packageName, frontend) if err != nil { ret.Code = -1 @@ -185,8 +214,8 @@ func getBazaarWidget(c *gin.Context) { } var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("keyword", false, &keyword)) { + return } ret.Data = map[string]interface{}{ @@ -204,8 +233,8 @@ func getInstalledWidget(c *gin.Context) { } var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("keyword", false, &keyword)) { + return } ret.Data = map[string]interface{}{ @@ -222,14 +251,15 @@ func installBazaarWidget(c *gin.Context) { return } - var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + var keyword, repoURL, repoHash, packageName string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("keyword", false, &keyword), + util.BindJsonArg("repoURL", true, &repoURL), + util.BindJsonArg("repoHash", true, &repoHash), + util.BindJsonArg("packageName", true, &packageName), + ) { + return } - - repoURL := arg["repoURL"].(string) - repoHash := arg["repoHash"].(string) - packageName := arg["packageName"].(string) err := model.InstallBazaarWidget(repoURL, repoHash, packageName) if err != nil { ret.Code = 1 @@ -252,12 +282,13 @@ func uninstallBazaarWidget(c *gin.Context) { return } - var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + var keyword, packageName string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("keyword", false, &keyword), + util.BindJsonArg("packageName", true, &packageName), + ) { + return } - - packageName := arg["packageName"].(string) err := model.UninstallBazaarWidget(packageName) if err != nil { ret.Code = -1 @@ -280,8 +311,8 @@ func getBazaarIcon(c *gin.Context) { } var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("keyword", false, &keyword)) { + return } ret.Data = map[string]interface{}{ @@ -299,8 +330,8 @@ func getInstalledIcon(c *gin.Context) { } var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("keyword", false, &keyword)) { + return } ret.Data = map[string]interface{}{ @@ -317,14 +348,15 @@ func installBazaarIcon(c *gin.Context) { return } - var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + var keyword, repoURL, repoHash, packageName string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("keyword", false, &keyword), + util.BindJsonArg("repoURL", true, &repoURL), + util.BindJsonArg("repoHash", true, &repoHash), + util.BindJsonArg("packageName", true, &packageName), + ) { + return } - - repoURL := arg["repoURL"].(string) - repoHash := arg["repoHash"].(string) - packageName := arg["packageName"].(string) err := model.InstallBazaarIcon(repoURL, repoHash, packageName) if err != nil { ret.Code = 1 @@ -348,12 +380,13 @@ func uninstallBazaarIcon(c *gin.Context) { return } - var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + var keyword, packageName string + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("keyword", false, &keyword), + util.BindJsonArg("packageName", true, &packageName), + ) { + return } - - packageName := arg["packageName"].(string) err := model.UninstallBazaarIcon(packageName) if err != nil { ret.Code = -1 @@ -377,8 +410,8 @@ func getBazaarTemplate(c *gin.Context) { } var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("keyword", false, &keyword)) { + return } ret.Data = map[string]interface{}{ @@ -396,8 +429,8 @@ func getInstalledTemplate(c *gin.Context) { } var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("keyword", false, &keyword)) { + return } ret.Data = map[string]interface{}{ @@ -510,15 +543,17 @@ func installBazaarTheme(c *gin.Context) { return } - var keyword string - if keywordArg := arg["keyword"]; nil != keywordArg { - keyword = keywordArg.(string) + var keyword, repoURL, repoHash, packageName string + var mode float64 + if !util.ParseJsonArgs(arg, ret, + util.BindJsonArg("keyword", false, &keyword), + util.BindJsonArg("repoURL", true, &repoURL), + util.BindJsonArg("repoHash", true, &repoHash), + util.BindJsonArg("packageName", true, &packageName), + util.BindJsonArg("mode", true, &mode), + ) { + return } - - repoURL := arg["repoURL"].(string) - repoHash := arg["repoHash"].(string) - packageName := arg["packageName"].(string) - mode := arg["mode"].(float64) update := false if nil != arg["update"] { update = arg["update"].(bool) @@ -530,6 +565,7 @@ func installBazaarTheme(c *gin.Context) { return } + // TODO 安装新主题之后,不应该始终取消外观模式“跟随系统” https://github.com/siyuan-note/siyuan/issues/16990 // 安装集市主题后不跟随系统切换外观模式 model.Conf.Appearance.ModeOS = false model.Conf.Save() diff --git a/kernel/util/net.go b/kernel/util/net.go index 1f39ca627..a57dfa58a 100644 --- a/kernel/util/net.go +++ b/kernel/util/net.go @@ -17,6 +17,7 @@ package util import ( + "fmt" "net" "net/http" "net/url" @@ -213,6 +214,58 @@ func JsonArg(c *gin.Context, result *gulu.Result) (arg map[string]interface{}, o return } +// ParseJsonArg 使用泛型从 JSON 参数中提取指定键的值。 +// - 如果 required 为 true 但参数缺失,则会在 ret.Msg 中写入 “[key] is required” +// - 如果参数存在但类型不匹配,则会在 ret.Msg 中写入 “[key] should be [T]” +// - 返回值 ok 为 false 时,表示提取失败或类型不匹配 +func ParseJsonArg[T any](key string, required bool, arg map[string]interface{}, ret *gulu.Result) (value T, ok bool) { + raw, exists := arg[key] + if !exists || raw == nil { + if required { + ret.Code = -1 + ret.Msg = key + " is required" + } else { + ok = true + } + return + } + + value, ok = raw.(T) + if !ok { + var zero T + ret.Code = -1 + ret.Msg = fmt.Sprintf("%s should be %T", key, zero) + } + return +} + +// JsonArgParseFunc 为单次提取函数,用于 ParseJsonArgs 批量提取。 +type JsonArgParseFunc func(arg map[string]interface{}, ret *gulu.Result) bool + +// BindJsonArg 创建一个提取函数:从 arg 取 key 并写入 dest,供 ParseJsonArgs 使用。 +func BindJsonArg[T any](key string, required bool, dest *T) JsonArgParseFunc { + return func(arg map[string]interface{}, ret *gulu.Result) bool { + v, ok := ParseJsonArg[T](key, required, arg, ret) + if !ok { + return false + } + *dest = v + return true + } +} + +// ParseJsonArgs 按顺序执行多个提取函数。 +// - 任一失败返回 false 并在 ret 中写入错误信息 +// - 全部成功返回 true +func ParseJsonArgs(arg map[string]interface{}, ret *gulu.Result, extractors ...JsonArgParseFunc) bool { + for _, ext := range extractors { + if !ext(arg, ret) { + return false + } + } + return true +} + func InvalidIDPattern(idArg string, result *gulu.Result) bool { if ast.IsNodeIDPattern(idArg) { return false From ba733eedfd9b1285a983dd5e6c86e56852cbe86e Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:53:06 +0800 Subject: [PATCH 2/7] :art: Normalize bazaar naming and reorganize kernel/bazaar structure (#17133) --- app/src/dialog/processSystem.ts | 8 +- kernel/bazaar/package.go | 435 -------------------------------- kernel/bazaar/readme.go | 160 ++++++++++++ kernel/bazaar/stage.go | 332 ++++++++++++++++++++++++ 4 files changed, 496 insertions(+), 439 deletions(-) create mode 100644 kernel/bazaar/readme.go create mode 100644 kernel/bazaar/stage.go diff --git a/app/src/dialog/processSystem.ts b/app/src/dialog/processSystem.ts index 64814a0f2..7a5d73bcc 100644 --- a/app/src/dialog/processSystem.ts +++ b/app/src/dialog/processSystem.ts @@ -537,14 +537,14 @@ export const setTitle = (title: string) => { }; export const downloadProgress = (data: { id: string, percent: number }) => { - const bazzarSideElement = document.querySelector("#configBazaarReadme .item__side"); - if (!bazzarSideElement) { + const bazaarSideElement = document.querySelector("#configBazaarReadme .item__side"); + if (!bazaarSideElement) { return; } - if (data.id !== JSON.parse(bazzarSideElement.getAttribute("data-obj")).repoURL) { + if (data.id !== JSON.parse(bazaarSideElement.getAttribute("data-obj")).repoURL) { return; } - const btnElement = bazzarSideElement.querySelector('[data-type="install"]') as HTMLElement; + const btnElement = bazaarSideElement.querySelector('[data-type="install"]') as HTMLElement; if (btnElement) { if (data.percent >= 1) { btnElement.parentElement.classList.add("fn__none"); diff --git a/kernel/bazaar/package.go b/kernel/bazaar/package.go index 59004a03a..6ecaae1c7 100644 --- a/kernel/bazaar/package.go +++ b/kernel/bazaar/package.go @@ -18,7 +18,6 @@ package bazaar import ( "bytes" - "context" "errors" "fmt" "os" @@ -28,7 +27,6 @@ import ( "time" "github.com/88250/gulu" - "github.com/88250/lute" "github.com/araddon/dateparse" "github.com/imroc/req/v3" gcache "github.com/patrickmn/go-cache" @@ -37,9 +35,6 @@ import ( "github.com/siyuan-note/logging" "github.com/siyuan-note/siyuan/kernel/util" "golang.org/x/mod/semver" - "golang.org/x/sync/singleflight" - textUnicode "golang.org/x/text/encoding/unicode" - "golang.org/x/text/transform" ) // LocaleStrings 表示按语种 key 的字符串表,key 为语种如 "default"、"en_US"、"zh_CN" 等 @@ -274,388 +269,6 @@ func ThemeJSON(themeDirName string) (ret *Theme, err error) { return } -var cachedStageIndex = map[string]*StageIndex{} -var stageIndexCacheTime int64 -var stageIndexLock = sync.RWMutex{} - -type StageBazaarResult struct { - StageIndex *StageIndex // stage 索引 - BazaarIndex map[string]*bazaarPackage // bazaar 索引 - Online bool // online 状态 - StageErr error // stage 错误 -} - -var stageBazaarFlight singleflight.Group -var onlineCheckFlight singleflight.Group - -// getStageAndBazaar 获取 stage 索引和 bazaar 索引,相同 pkgType 的并发调用会合并为一次实际请求 (single-flight) -func getStageAndBazaar(pkgType string) (result StageBazaarResult) { - key := "stageBazaar:" + pkgType - v, err, _ := stageBazaarFlight.Do(key, func() (interface{}, error) { - return getStageAndBazaar0(pkgType), nil - }) - if err != nil { - return - } - result = v.(StageBazaarResult) - return -} - -// getStageAndBazaar0 执行一次 stage 和 bazaar 索引拉取 -func getStageAndBazaar0(pkgType string) (result StageBazaarResult) { - stageIndex, stageErr := getStageIndexFromCache(pkgType) - bazaarIndex := getBazaarIndexFromCache() - if nil != stageIndex && nil != bazaarIndex { - // 两者都从缓存返回,不需要 online 检查 - return StageBazaarResult{ - StageIndex: stageIndex, - BazaarIndex: bazaarIndex, - Online: true, - StageErr: stageErr, - } - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - var onlineResult bool - onlineDone := make(chan bool, 1) - wg := &sync.WaitGroup{} - wg.Add(3) - go func() { - defer wg.Done() - onlineResult = isBazzarOnline() - onlineDone <- true - }() - go func() { - defer wg.Done() - stageIndex, stageErr = getStageIndex(ctx, pkgType) - }() - go func() { - defer wg.Done() - bazaarIndex = getBazaarIndex(ctx) - }() - - <-onlineDone - if !onlineResult { - // 不在线时立即取消其他请求并返回结果,避免等待 HTTP 请求超时 - cancel() - return StageBazaarResult{ - StageIndex: stageIndex, - BazaarIndex: bazaarIndex, - Online: false, - StageErr: stageErr, - } - } - - // 在线时等待所有请求完成 - wg.Wait() - - return StageBazaarResult{ - StageIndex: stageIndex, - BazaarIndex: bazaarIndex, - Online: onlineResult, - StageErr: stageErr, - } -} - -// getStageIndexFromCache 仅从缓存获取 stage 索引,过期或无缓存时返回 nil -func getStageIndexFromCache(pkgType string) (ret *StageIndex, err error) { - stageIndexLock.RLock() - cacheTime := stageIndexCacheTime - cached := cachedStageIndex[pkgType] - stageIndexLock.RUnlock() - if util.RhyCacheDuration >= time.Now().Unix()-cacheTime && nil != cached { - ret = cached - } - return -} - -// getStageIndex 获取 stage 索引 -func getStageIndex(ctx context.Context, pkgType string) (ret *StageIndex, err error) { - if cached, cacheErr := getStageIndexFromCache(pkgType); nil != cached { - ret = cached - err = cacheErr - return - } - - var rhyRet map[string]interface{} - rhyRet, err = util.GetRhyResult(ctx, false) - if nil != err { - return - } - - stageIndexLock.Lock() - defer stageIndexLock.Unlock() - - bazaarHash := rhyRet["bazaar"].(string) - ret = &StageIndex{} - request := httpclient.NewBrowserRequest() - u := util.BazaarOSSServer + "/bazaar@" + bazaarHash + "/stage/" + pkgType + ".json" - resp, reqErr := request.SetContext(ctx).SetSuccessResult(ret).Get(u) - if nil != reqErr { - logging.LogErrorf("get community stage index [%s] failed: %s", u, reqErr) - err = reqErr - return - } - if 200 != resp.StatusCode { - logging.LogErrorf("get community stage index [%s] failed: %d", u, resp.StatusCode) - err = errors.New("get stage index failed") - return - } - - stageIndexCacheTime = time.Now().Unix() - cachedStageIndex[pkgType] = ret - return -} - -func isOutdatedTheme(theme *Theme, bazaarThemes []*Theme) bool { - if !strings.HasPrefix(theme.URL, "https://github.com/") { - return false - } - - repo := strings.TrimPrefix(theme.URL, "https://github.com/") - parts := strings.Split(repo, "/") - if 2 != len(parts) || "" == strings.TrimSpace(parts[1]) { - return false - } - - for _, pkg := range bazaarThemes { - if theme.Name == pkg.Name && 0 > semver.Compare("v"+theme.Version, "v"+pkg.Version) { - theme.RepoHash = pkg.RepoHash - return true - } - } - return false -} - -func isOutdatedIcon(icon *Icon, bazaarIcons []*Icon) bool { - if !strings.HasPrefix(icon.URL, "https://github.com/") { - return false - } - - repo := strings.TrimPrefix(icon.URL, "https://github.com/") - parts := strings.Split(repo, "/") - if 2 != len(parts) || "" == strings.TrimSpace(parts[1]) { - return false - } - - for _, pkg := range bazaarIcons { - if icon.Name == pkg.Name && 0 > semver.Compare("v"+icon.Version, "v"+pkg.Version) { - icon.RepoHash = pkg.RepoHash - return true - } - } - return false -} - -func isOutdatedPlugin(plugin *Plugin, bazaarPlugins []*Plugin) bool { - if !strings.HasPrefix(plugin.URL, "https://github.com/") { - return false - } - - repo := strings.TrimPrefix(plugin.URL, "https://github.com/") - parts := strings.Split(repo, "/") - if 2 != len(parts) || "" == strings.TrimSpace(parts[1]) { - return false - } - - for _, pkg := range bazaarPlugins { - if plugin.Name == pkg.Name && 0 > semver.Compare("v"+plugin.Version, "v"+pkg.Version) { - plugin.RepoHash = pkg.RepoHash - return true - } - } - return false -} - -func isOutdatedWidget(widget *Widget, bazaarWidgets []*Widget) bool { - if !strings.HasPrefix(widget.URL, "https://github.com/") { - return false - } - - repo := strings.TrimPrefix(widget.URL, "https://github.com/") - parts := strings.Split(repo, "/") - if 2 != len(parts) || "" == strings.TrimSpace(parts[1]) { - return false - } - - for _, pkg := range bazaarWidgets { - if widget.Name == pkg.Name && 0 > semver.Compare("v"+widget.Version, "v"+pkg.Version) { - widget.RepoHash = pkg.RepoHash - return true - } - } - return false -} - -func isOutdatedTemplate(template *Template, bazaarTemplates []*Template) bool { - if !strings.HasPrefix(template.URL, "https://github.com/") { - return false - } - - repo := strings.TrimPrefix(template.URL, "https://github.com/") - parts := strings.Split(repo, "/") - if 2 != len(parts) || "" == strings.TrimSpace(parts[1]) { - return false - } - - for _, pkg := range bazaarTemplates { - if template.Name == pkg.Name && 0 > semver.Compare("v"+template.Version, "v"+pkg.Version) { - template.RepoHash = pkg.RepoHash - return true - } - } - return false -} - -func isBazzarOnline() bool { - v, err, _ := onlineCheckFlight.Do("bazaarOnline", func() (interface{}, error) { - return isBazzarOnline0(), nil - }) - if err != nil { - return false - } - return v.(bool) -} - -func isBazzarOnline0() (ret bool) { - // Improve marketplace loading when offline https://github.com/siyuan-note/siyuan/issues/12050 - ret = util.IsOnline(util.BazaarOSSServer+"/204", true, 3000) - if !ret { - util.PushErrMsg(util.Langs[util.Lang][24], 5000) - } - return -} - -func GetPackageREADME(repoURL, repoHash, packageType string) (ret string) { - repoURLHash := repoURL + "@" + repoHash - - stageIndexLock.RLock() - stageIndex := cachedStageIndex[packageType] - stageIndexLock.RUnlock() - if nil == stageIndex { - return - } - - url := strings.TrimPrefix(repoURLHash, "https://github.com/") - var repo *StageRepo - for _, r := range stageIndex.Repos { - if r.URL == url { - repo = r - break - } - } - if nil == repo || nil == repo.Package { - return - } - - readme := getPreferredReadme(repo.Package.Readme) - - data, err := downloadPackage(repoURLHash+"/"+readme, false, "") - if err != nil { - ret = fmt.Sprintf("Load bazaar package's preferred README(%s) failed: %s", readme, err.Error()) - // 回退到 Default README - var defaultReadme string - if len(repo.Package.Readme) > 0 { - defaultReadme = repo.Package.Readme["default"] - } - if "" == strings.TrimSpace(defaultReadme) { - defaultReadme = "README.md" - } - if readme != defaultReadme { - data, err = downloadPackage(repoURLHash+"/"+defaultReadme, false, "") - if err != nil { - ret += fmt.Sprintf("
Load bazaar package's default README(%s) failed: %s", defaultReadme, err.Error()) - } - } - // 回退到 README.md - if err != nil && readme != "README.md" && defaultReadme != "README.md" { - data, err = downloadPackage(repoURLHash+"/README.md", false, "") - if err != nil { - ret += fmt.Sprintf("
Load bazaar package's README.md failed: %s", err.Error()) - return - } - } else if err != nil { - return - } - } - - if 2 < len(data) { - if 255 == data[0] && 254 == data[1] { - data, _, err = transform.Bytes(textUnicode.UTF16(textUnicode.LittleEndian, textUnicode.ExpectBOM).NewDecoder(), data) - } else if 254 == data[0] && 255 == data[1] { - data, _, err = transform.Bytes(textUnicode.UTF16(textUnicode.BigEndian, textUnicode.ExpectBOM).NewDecoder(), data) - } - } - - ret, err = renderREADME(repoURL, data) - return -} - -func loadInstalledReadme(installPath, basePath string, readme LocaleStrings) (ret string) { - readmeFilename := getPreferredReadme(readme) - readmeData, readErr := os.ReadFile(filepath.Join(installPath, readmeFilename)) - if nil == readErr { - ret, _ = renderLocalREADME(basePath, readmeData) - return - } - - logging.LogWarnf("read installed %s failed: %s", readmeFilename, readErr) - ret = fmt.Sprintf("File %s not found", readmeFilename) - // 回退到 Default README - var defaultReadme string - if len(readme) > 0 { - defaultReadme = strings.TrimSpace(readme["default"]) - } - if "" == defaultReadme { - defaultReadme = "README.md" - } - if readmeFilename != defaultReadme { - readmeData, readErr = os.ReadFile(filepath.Join(installPath, defaultReadme)) - if nil == readErr { - ret, _ = renderLocalREADME(basePath, readmeData) - return - } - logging.LogWarnf("read installed %s failed: %s", defaultReadme, readErr) - ret += fmt.Sprintf("
File %s not found", defaultReadme) - } - // 回退到 README.md - if nil != readErr && readmeFilename != "README.md" && defaultReadme != "README.md" { - readmeData, readErr = os.ReadFile(filepath.Join(installPath, "README.md")) - if nil == readErr { - ret, _ = renderLocalREADME(basePath, readmeData) - return - } - logging.LogWarnf("read installed README.md failed: %s", readErr) - ret += "
File README.md not found" - } - return -} - -func renderREADME(repoURL string, mdData []byte) (ret string, err error) { - mdData = bytes.TrimPrefix(mdData, []byte("\xef\xbb\xbf")) - luteEngine := lute.New() - luteEngine.SetSoftBreak2HardBreak(false) - luteEngine.SetCodeSyntaxHighlight(false) - linkBase := "https://cdn.jsdelivr.net/gh/" + strings.TrimPrefix(repoURL, "https://github.com/") - luteEngine.SetLinkBase(linkBase) - ret = luteEngine.Md2HTML(string(mdData)) - ret = util.LinkTarget(ret, linkBase) - return -} - -func renderLocalREADME(basePath string, mdData []byte) (ret string, err error) { - mdData = bytes.TrimPrefix(mdData, []byte("\xef\xbb\xbf")) - luteEngine := lute.New() - luteEngine.SetSoftBreak2HardBreak(false) - luteEngine.SetCodeSyntaxHighlight(false) - linkBase := basePath - luteEngine.SetLinkBase(linkBase) - ret = luteEngine.Md2HTML(string(mdData)) - ret = util.LinkTarget(ret, linkBase) - return -} - var ( packageLocks = map[string]*sync.Mutex{} packageLocksLock = sync.Mutex{} @@ -779,54 +392,6 @@ func formatUpdated(updated string) (ret string) { return } -type bazaarPackage struct { - Name string `json:"name"` - Downloads int `json:"downloads"` -} - -var cachedBazaarIndex = map[string]*bazaarPackage{} -var bazaarIndexCacheTime int64 -var bazaarIndexLock = sync.RWMutex{} - -// getBazaarIndexFromCache 仅从缓存获取 bazaar 索引,过期或无缓存时返回 nil -func getBazaarIndexFromCache() (ret map[string]*bazaarPackage) { - bazaarIndexLock.RLock() - cacheTime := bazaarIndexCacheTime - cached := cachedBazaarIndex - hasData := 0 < len(cached) - bazaarIndexLock.RUnlock() - if util.RhyCacheDuration >= time.Now().Unix()-cacheTime && hasData { - ret = cached - } else { - ret = nil - } - return -} - -// getBazaarIndex 获取 bazaar 索引 -func getBazaarIndex(ctx context.Context) map[string]*bazaarPackage { - if cached := getBazaarIndexFromCache(); nil != cached { - return cached - } - - bazaarIndexLock.Lock() - defer bazaarIndexLock.Unlock() - - request := httpclient.NewBrowserRequest() - u := util.BazaarStatServer + "/bazaar/index.json" - resp, reqErr := request.SetContext(ctx).SetSuccessResult(&cachedBazaarIndex).Get(u) - if nil != reqErr { - logging.LogErrorf("get bazaar index [%s] failed: %s", u, reqErr) - return cachedBazaarIndex - } - if 200 != resp.StatusCode { - logging.LogErrorf("get bazaar index [%s] failed: %d", u, resp.StatusCode) - return cachedBazaarIndex - } - bazaarIndexCacheTime = time.Now().Unix() - return cachedBazaarIndex -} - // Add marketplace package config item `minAppVersion` https://github.com/siyuan-note/siyuan/issues/8330 func disallowInstallBazaarPackage(pkg *Package) bool { // 如果包没有指定 minAppVersion,则允许安装 diff --git a/kernel/bazaar/readme.go b/kernel/bazaar/readme.go new file mode 100644 index 000000000..ff3a350cb --- /dev/null +++ b/kernel/bazaar/readme.go @@ -0,0 +1,160 @@ +// SiYuan - Refactor your thinking +// Copyright (c) 2020-present, b3log.org +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package bazaar + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/88250/lute" + "github.com/siyuan-note/logging" + "github.com/siyuan-note/siyuan/kernel/util" + textUnicode "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" +) + +func GetPackageREADME(repoURL, repoHash, packageType string) (ret string) { + repoURLHash := repoURL + "@" + repoHash + + stageIndexLock.RLock() + stageIndex := cachedStageIndex[packageType] + stageIndexLock.RUnlock() + if nil == stageIndex { + return + } + + url := strings.TrimPrefix(repoURLHash, "https://github.com/") + var repo *StageRepo + for _, r := range stageIndex.Repos { + if r.URL == url { + repo = r + break + } + } + if nil == repo || nil == repo.Package { + return + } + + readme := getPreferredReadme(repo.Package.Readme) + + data, err := downloadPackage(repoURLHash+"/"+readme, false, "") + if err != nil { + ret = fmt.Sprintf("Load bazaar package's preferred README(%s) failed: %s", readme, err.Error()) + // 回退到 Default README + var defaultReadme string + if len(repo.Package.Readme) > 0 { + defaultReadme = repo.Package.Readme["default"] + } + if "" == strings.TrimSpace(defaultReadme) { + defaultReadme = "README.md" + } + if readme != defaultReadme { + data, err = downloadPackage(repoURLHash+"/"+defaultReadme, false, "") + if err != nil { + ret += fmt.Sprintf("
Load bazaar package's default README(%s) failed: %s", defaultReadme, err.Error()) + } + } + // 回退到 README.md + if err != nil && readme != "README.md" && defaultReadme != "README.md" { + data, err = downloadPackage(repoURLHash+"/README.md", false, "") + if err != nil { + ret += fmt.Sprintf("
Load bazaar package's README.md failed: %s", err.Error()) + return + } + } else if err != nil { + return + } + } + + if 2 < len(data) { + if 255 == data[0] && 254 == data[1] { + data, _, err = transform.Bytes(textUnicode.UTF16(textUnicode.LittleEndian, textUnicode.ExpectBOM).NewDecoder(), data) + } else if 254 == data[0] && 255 == data[1] { + data, _, err = transform.Bytes(textUnicode.UTF16(textUnicode.BigEndian, textUnicode.ExpectBOM).NewDecoder(), data) + } + } + + ret, err = renderREADME(repoURL, data) + return +} + +func loadInstalledReadme(installPath, basePath string, readme LocaleStrings) (ret string) { + readmeFilename := getPreferredReadme(readme) + readmeData, readErr := os.ReadFile(filepath.Join(installPath, readmeFilename)) + if nil == readErr { + ret, _ = renderLocalREADME(basePath, readmeData) + return + } + + logging.LogWarnf("read installed %s failed: %s", readmeFilename, readErr) + ret = fmt.Sprintf("File %s not found", readmeFilename) + // 回退到 Default README + var defaultReadme string + if len(readme) > 0 { + defaultReadme = strings.TrimSpace(readme["default"]) + } + if "" == defaultReadme { + defaultReadme = "README.md" + } + if readmeFilename != defaultReadme { + readmeData, readErr = os.ReadFile(filepath.Join(installPath, defaultReadme)) + if nil == readErr { + ret, _ = renderLocalREADME(basePath, readmeData) + return + } + logging.LogWarnf("read installed %s failed: %s", defaultReadme, readErr) + ret += fmt.Sprintf("
File %s not found", defaultReadme) + } + // 回退到 README.md + if nil != readErr && readmeFilename != "README.md" && defaultReadme != "README.md" { + readmeData, readErr = os.ReadFile(filepath.Join(installPath, "README.md")) + if nil == readErr { + ret, _ = renderLocalREADME(basePath, readmeData) + return + } + logging.LogWarnf("read installed README.md failed: %s", readErr) + ret += "
File README.md not found" + } + return +} + +func renderREADME(repoURL string, mdData []byte) (ret string, err error) { + mdData = bytes.TrimPrefix(mdData, []byte("\xef\xbb\xbf")) + luteEngine := lute.New() + luteEngine.SetSoftBreak2HardBreak(false) + luteEngine.SetCodeSyntaxHighlight(false) + linkBase := "https://cdn.jsdelivr.net/gh/" + strings.TrimPrefix(repoURL, "https://github.com/") + luteEngine.SetLinkBase(linkBase) + ret = luteEngine.Md2HTML(string(mdData)) + ret = util.LinkTarget(ret, linkBase) + return +} + +func renderLocalREADME(basePath string, mdData []byte) (ret string, err error) { + mdData = bytes.TrimPrefix(mdData, []byte("\xef\xbb\xbf")) + luteEngine := lute.New() + luteEngine.SetSoftBreak2HardBreak(false) + luteEngine.SetCodeSyntaxHighlight(false) + linkBase := basePath + luteEngine.SetLinkBase(linkBase) + ret = luteEngine.Md2HTML(string(mdData)) + ret = util.LinkTarget(ret, linkBase) + return +} diff --git a/kernel/bazaar/stage.go b/kernel/bazaar/stage.go new file mode 100644 index 000000000..6b90f4797 --- /dev/null +++ b/kernel/bazaar/stage.go @@ -0,0 +1,332 @@ +// SiYuan - Refactor your thinking +// Copyright (c) 2020-present, b3log.org +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package bazaar + +import ( + "context" + "errors" + "strings" + "sync" + "time" + + "github.com/siyuan-note/httpclient" + "github.com/siyuan-note/logging" + "github.com/siyuan-note/siyuan/kernel/util" + "golang.org/x/mod/semver" + "golang.org/x/sync/singleflight" +) + +var cachedStageIndex = map[string]*StageIndex{} +var stageIndexCacheTime int64 +var stageIndexLock = sync.RWMutex{} + +type StageBazaarResult struct { + StageIndex *StageIndex // stage 索引 + BazaarIndex map[string]*bazaarPackage // bazaar 索引 + Online bool // online 状态 + StageErr error // stage 错误 +} + +var stageBazaarFlight singleflight.Group +var onlineCheckFlight singleflight.Group + +// getStageAndBazaar 获取 stage 索引和 bazaar 索引,相同 pkgType 的并发调用会合并为一次实际请求 (single-flight) +func getStageAndBazaar(pkgType string) (result StageBazaarResult) { + key := "stageBazaar:" + pkgType + v, err, _ := stageBazaarFlight.Do(key, func() (interface{}, error) { + return getStageAndBazaar0(pkgType), nil + }) + if err != nil { + return + } + result = v.(StageBazaarResult) + return +} + +// getStageAndBazaar0 执行一次 stage 和 bazaar 索引拉取 +func getStageAndBazaar0(pkgType string) (result StageBazaarResult) { + stageIndex, stageErr := getStageIndexFromCache(pkgType) + bazaarIndex := getBazaarIndexFromCache() + if nil != stageIndex && nil != bazaarIndex { + // 两者都从缓存返回,不需要 online 检查 + return StageBazaarResult{ + StageIndex: stageIndex, + BazaarIndex: bazaarIndex, + Online: true, + StageErr: stageErr, + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var onlineResult bool + onlineDone := make(chan bool, 1) + wg := &sync.WaitGroup{} + wg.Add(3) + go func() { + defer wg.Done() + onlineResult = isBazzarOnline() + onlineDone <- true + }() + go func() { + defer wg.Done() + stageIndex, stageErr = getStageIndex(ctx, pkgType) + }() + go func() { + defer wg.Done() + bazaarIndex = getBazaarIndex(ctx) + }() + + <-onlineDone + if !onlineResult { + // 不在线时立即取消其他请求并返回结果,避免等待 HTTP 请求超时 + cancel() + return StageBazaarResult{ + StageIndex: stageIndex, + BazaarIndex: bazaarIndex, + Online: false, + StageErr: stageErr, + } + } + + // 在线时等待所有请求完成 + wg.Wait() + + return StageBazaarResult{ + StageIndex: stageIndex, + BazaarIndex: bazaarIndex, + Online: onlineResult, + StageErr: stageErr, + } +} + +// getStageIndexFromCache 仅从缓存获取 stage 索引,过期或无缓存时返回 nil +func getStageIndexFromCache(pkgType string) (ret *StageIndex, err error) { + stageIndexLock.RLock() + cacheTime := stageIndexCacheTime + cached := cachedStageIndex[pkgType] + stageIndexLock.RUnlock() + if util.RhyCacheDuration >= time.Now().Unix()-cacheTime && nil != cached { + ret = cached + } + return +} + +// getStageIndex 获取 stage 索引 +func getStageIndex(ctx context.Context, pkgType string) (ret *StageIndex, err error) { + if cached, cacheErr := getStageIndexFromCache(pkgType); nil != cached { + ret = cached + err = cacheErr + return + } + + var rhyRet map[string]interface{} + rhyRet, err = util.GetRhyResult(ctx, false) + if nil != err { + return + } + + stageIndexLock.Lock() + defer stageIndexLock.Unlock() + + bazaarHash := rhyRet["bazaar"].(string) + ret = &StageIndex{} + request := httpclient.NewBrowserRequest() + u := util.BazaarOSSServer + "/bazaar@" + bazaarHash + "/stage/" + pkgType + ".json" + resp, reqErr := request.SetContext(ctx).SetSuccessResult(ret).Get(u) + if nil != reqErr { + logging.LogErrorf("get community stage index [%s] failed: %s", u, reqErr) + err = reqErr + return + } + if 200 != resp.StatusCode { + logging.LogErrorf("get community stage index [%s] failed: %d", u, resp.StatusCode) + err = errors.New("get stage index failed") + return + } + + stageIndexCacheTime = time.Now().Unix() + cachedStageIndex[pkgType] = ret + return +} + +func isOutdatedTheme(theme *Theme, bazaarThemes []*Theme) bool { + if !strings.HasPrefix(theme.URL, "https://github.com/") { + return false + } + + repo := strings.TrimPrefix(theme.URL, "https://github.com/") + parts := strings.Split(repo, "/") + if 2 != len(parts) || "" == strings.TrimSpace(parts[1]) { + return false + } + + for _, pkg := range bazaarThemes { + if theme.Name == pkg.Name && 0 > semver.Compare("v"+theme.Version, "v"+pkg.Version) { + theme.RepoHash = pkg.RepoHash + return true + } + } + return false +} + +func isOutdatedIcon(icon *Icon, bazaarIcons []*Icon) bool { + if !strings.HasPrefix(icon.URL, "https://github.com/") { + return false + } + + repo := strings.TrimPrefix(icon.URL, "https://github.com/") + parts := strings.Split(repo, "/") + if 2 != len(parts) || "" == strings.TrimSpace(parts[1]) { + return false + } + + for _, pkg := range bazaarIcons { + if icon.Name == pkg.Name && 0 > semver.Compare("v"+icon.Version, "v"+pkg.Version) { + icon.RepoHash = pkg.RepoHash + return true + } + } + return false +} + +func isOutdatedPlugin(plugin *Plugin, bazaarPlugins []*Plugin) bool { + if !strings.HasPrefix(plugin.URL, "https://github.com/") { + return false + } + + repo := strings.TrimPrefix(plugin.URL, "https://github.com/") + parts := strings.Split(repo, "/") + if 2 != len(parts) || "" == strings.TrimSpace(parts[1]) { + return false + } + + for _, pkg := range bazaarPlugins { + if plugin.Name == pkg.Name && 0 > semver.Compare("v"+plugin.Version, "v"+pkg.Version) { + plugin.RepoHash = pkg.RepoHash + return true + } + } + return false +} + +func isOutdatedWidget(widget *Widget, bazaarWidgets []*Widget) bool { + if !strings.HasPrefix(widget.URL, "https://github.com/") { + return false + } + + repo := strings.TrimPrefix(widget.URL, "https://github.com/") + parts := strings.Split(repo, "/") + if 2 != len(parts) || "" == strings.TrimSpace(parts[1]) { + return false + } + + for _, pkg := range bazaarWidgets { + if widget.Name == pkg.Name && 0 > semver.Compare("v"+widget.Version, "v"+pkg.Version) { + widget.RepoHash = pkg.RepoHash + return true + } + } + return false +} + +func isOutdatedTemplate(template *Template, bazaarTemplates []*Template) bool { + if !strings.HasPrefix(template.URL, "https://github.com/") { + return false + } + + repo := strings.TrimPrefix(template.URL, "https://github.com/") + parts := strings.Split(repo, "/") + if 2 != len(parts) || "" == strings.TrimSpace(parts[1]) { + return false + } + + for _, pkg := range bazaarTemplates { + if template.Name == pkg.Name && 0 > semver.Compare("v"+template.Version, "v"+pkg.Version) { + template.RepoHash = pkg.RepoHash + return true + } + } + return false +} + +func isBazzarOnline() bool { + v, err, _ := onlineCheckFlight.Do("bazaarOnline", func() (interface{}, error) { + return isBazzarOnline0(), nil + }) + if err != nil { + return false + } + return v.(bool) +} + +func isBazzarOnline0() (ret bool) { + // Improve marketplace loading when offline https://github.com/siyuan-note/siyuan/issues/12050 + ret = util.IsOnline(util.BazaarOSSServer+"/204", true, 3000) + if !ret { + util.PushErrMsg(util.Langs[util.Lang][24], 5000) + } + return +} + +type bazaarPackage struct { + Name string `json:"name"` + Downloads int `json:"downloads"` +} + +var cachedBazaarIndex = map[string]*bazaarPackage{} +var bazaarIndexCacheTime int64 +var bazaarIndexLock = sync.RWMutex{} + +// getBazaarIndexFromCache 仅从缓存获取 bazaar 索引,过期或无缓存时返回 nil +func getBazaarIndexFromCache() (ret map[string]*bazaarPackage) { + bazaarIndexLock.RLock() + cacheTime := bazaarIndexCacheTime + cached := cachedBazaarIndex + hasData := 0 < len(cached) + bazaarIndexLock.RUnlock() + if util.RhyCacheDuration >= time.Now().Unix()-cacheTime && hasData { + ret = cached + } else { + ret = nil + } + return +} + +// getBazaarIndex 获取 bazaar 索引 +func getBazaarIndex(ctx context.Context) map[string]*bazaarPackage { + if cached := getBazaarIndexFromCache(); nil != cached { + return cached + } + + bazaarIndexLock.Lock() + defer bazaarIndexLock.Unlock() + + request := httpclient.NewBrowserRequest() + u := util.BazaarStatServer + "/bazaar/index.json" + resp, reqErr := request.SetContext(ctx).SetSuccessResult(&cachedBazaarIndex).Get(u) + if nil != reqErr { + logging.LogErrorf("get bazaar index [%s] failed: %s", u, reqErr) + return cachedBazaarIndex + } + if 200 != resp.StatusCode { + logging.LogErrorf("get bazaar index [%s] failed: %d", u, resp.StatusCode) + return cachedBazaarIndex + } + bazaarIndexCacheTime = time.Now().Unix() + return cachedBazaarIndex +} From 26c378a8209e788bc1e5e6f879618cf7f9ee3de4 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:33:35 +0800 Subject: [PATCH 3/7] :art: Unified file listener logic (#17134) --- kernel/api/ui.go | 2 +- kernel/main.go | 1 + kernel/model/appearance.go | 186 +++++++++----------------- kernel/model/assets_watcher.go | 31 +++-- kernel/model/assets_watcher_darwin.go | 37 ++--- kernel/model/bazzar.go | 9 +- kernel/model/emojis_watcher.go | 33 +++-- kernel/model/emojis_watcher_darwin.go | 37 ++--- kernel/model/themes_watcher.go | 161 ++++++++++++++++++++++ kernel/model/themes_watcher_darwin.go | 156 +++++++++++++++++++++ 10 files changed, 462 insertions(+), 191 deletions(-) create mode 100644 kernel/model/themes_watcher.go create mode 100644 kernel/model/themes_watcher_darwin.go diff --git a/kernel/api/ui.go b/kernel/api/ui.go index dcbec4ebe..8a4d6302a 100644 --- a/kernel/api/ui.go +++ b/kernel/api/ui.go @@ -76,5 +76,5 @@ func reloadIcon(c *gin.Context) { ret := gulu.Ret.NewResult() defer c.JSON(http.StatusOK, ret) - model.ReloadIcon() + model.LoadIcons() } diff --git a/kernel/main.go b/kernel/main.go index c010d9f78..466b9ddb2 100644 --- a/kernel/main.go +++ b/kernel/main.go @@ -55,5 +55,6 @@ func main() { model.WatchAssets() model.WatchEmojis() + model.WatchThemes() model.HandleSignal() } diff --git a/kernel/model/appearance.go b/kernel/model/appearance.go index 2751ddd01..60da7e0f1 100644 --- a/kernel/model/appearance.go +++ b/kernel/model/appearance.go @@ -21,11 +21,9 @@ import ( "os" "path/filepath" "strings" - "sync" "time" "github.com/88250/gulu" - "github.com/fsnotify/fsnotify" "github.com/siyuan-note/filelock" "github.com/siyuan-note/logging" "github.com/siyuan-note/siyuan/kernel/bazaar" @@ -41,15 +39,17 @@ func InitAppearance() { return } - unloadThemes() from := filepath.Join(util.WorkingDir, "appearance") if err := filelock.Copy(from, util.AppearancePath); err != nil { logging.LogErrorf("copy appearance resources from [%s] to [%s] failed: %s", from, util.AppearancePath, err) util.ReportFileSysFatalError(err) return } - loadThemes() + loadThemes() + LoadIcons() + + Conf.m.Lock() if !containTheme(Conf.Appearance.ThemeDark, Conf.Appearance.DarkThemes) { Conf.Appearance.ThemeDark = "midnight" Conf.Appearance.ThemeJS = false @@ -58,11 +58,10 @@ func InitAppearance() { Conf.Appearance.ThemeLight = "daylight" Conf.Appearance.ThemeJS = false } - - loadIcons() if !gulu.Str.Contains(Conf.Appearance.Icon, Conf.Appearance.Icons) { Conf.Appearance.Icon = "material" } + Conf.m.Unlock() Conf.Save() @@ -78,36 +77,6 @@ func containTheme(name string, themes []*conf.AppearanceTheme) bool { return false } -var themeWatchers = sync.Map{} // [string]*fsnotify.Watcher{} - -func closeThemeWatchers() { - themeWatchers.Range(func(key, value interface{}) bool { - if err := value.(*fsnotify.Watcher).Close(); err != nil { - logging.LogErrorf("close file watcher failed: %s", err) - } - return true - }) -} - -func unloadThemes() { - if !util.IsPathRegularDirOrSymlinkDir(util.ThemesPath) { - return - } - - themeDirs, err := os.ReadDir(util.ThemesPath) - if err != nil { - logging.LogErrorf("read appearance themes folder failed: %s", err) - return - } - - for _, themeDir := range themeDirs { - if !util.IsDirRegularOrSymlink(themeDir) { - continue - } - unwatchTheme(filepath.Join(util.ThemesPath, themeDir.Name())) - } -} - func loadThemes() { themeDirs, err := os.ReadDir(util.ThemesPath) if err != nil { @@ -116,9 +85,13 @@ func loadThemes() { return } - Conf.Appearance.DarkThemes = nil - Conf.Appearance.LightThemes = nil + var darkThemes, lightThemes []*conf.AppearanceTheme var daylightTheme, midnightTheme *conf.AppearanceTheme + var themeVer string + var themeJS bool + mode := Conf.Appearance.Mode + themeLight := Conf.Appearance.ThemeLight + themeDark := Conf.Appearance.ThemeDark for _, themeDir := range themeDirs { if !util.IsDirRegularOrSymlink(themeDir) { continue @@ -129,10 +102,9 @@ func loadThemes() { continue } - modes := themeConf.Modes - for _, mode := range modes { + for _, mode := range themeConf.Modes { t := &conf.AppearanceTheme{Name: name} - if "midnight" == name || "daylight" == name { + if isBuiltInTheme(name) { t.Label = name + Conf.Language(281) } else { t.Label = name @@ -156,32 +128,37 @@ func loadThemes() { } if "dark" == mode { - Conf.Appearance.DarkThemes = append(Conf.Appearance.DarkThemes, t) + darkThemes = append(darkThemes, t) } else if "light" == mode { - Conf.Appearance.LightThemes = append(Conf.Appearance.LightThemes, t) + lightThemes = append(lightThemes, t) } } - if 0 == Conf.Appearance.Mode { - if Conf.Appearance.ThemeLight == name { - Conf.Appearance.ThemeVer = themeConf.Version - Conf.Appearance.ThemeJS = gulu.File.IsExist(filepath.Join(util.ThemesPath, name, "theme.js")) + if 0 == mode { + if themeLight == name { + themeVer = themeConf.Version + themeJS = gulu.File.IsExist(filepath.Join(util.ThemesPath, name, "theme.js")) } } else { - if Conf.Appearance.ThemeDark == name { - Conf.Appearance.ThemeVer = themeConf.Version - Conf.Appearance.ThemeJS = gulu.File.IsExist(filepath.Join(util.ThemesPath, name, "theme.js")) + if themeDark == name { + themeVer = themeConf.Version + themeJS = gulu.File.IsExist(filepath.Join(util.ThemesPath, name, "theme.js")) } } - - go watchTheme(filepath.Join(util.ThemesPath, name)) } - Conf.Appearance.LightThemes = append([]*conf.AppearanceTheme{daylightTheme}, Conf.Appearance.LightThemes...) - Conf.Appearance.DarkThemes = append([]*conf.AppearanceTheme{midnightTheme}, Conf.Appearance.DarkThemes...) + lightThemes = append([]*conf.AppearanceTheme{daylightTheme}, lightThemes...) + darkThemes = append([]*conf.AppearanceTheme{midnightTheme}, darkThemes...) + + Conf.m.Lock() + Conf.Appearance.DarkThemes = darkThemes + Conf.Appearance.LightThemes = lightThemes + Conf.Appearance.ThemeVer = themeVer + Conf.Appearance.ThemeJS = themeJS + Conf.m.Unlock() } -func loadIcons() { +func LoadIcons() { iconDirs, err := os.ReadDir(util.IconsPath) if err != nil { logging.LogErrorf("read appearance icons folder failed: %s", err) @@ -189,7 +166,9 @@ func loadIcons() { return } - Conf.Appearance.Icons = nil + var icons []string + var iconVer string + currentIcon := Conf.Appearance.Icon for _, iconDir := range iconDirs { if !util.IsDirRegularOrSymlink(iconDir) { continue @@ -199,77 +178,15 @@ func loadIcons() { if err != nil || nil == iconConf { continue } - Conf.Appearance.Icons = append(Conf.Appearance.Icons, name) - if Conf.Appearance.Icon == name { - Conf.Appearance.IconVer = iconConf.Version + icons = append(icons, name) + if currentIcon == name { + iconVer = iconConf.Version } } -} - -func ReloadIcon() { - loadIcons() -} - -func unwatchTheme(folder string) { - val, _ := themeWatchers.Load(folder) - if nil != val { - themeWatcher := val.(*fsnotify.Watcher) - themeWatcher.Close() - } -} - -func watchTheme(folder string) { - val, _ := themeWatchers.Load(folder) - var themeWatcher *fsnotify.Watcher - if nil != val { - themeWatcher = val.(*fsnotify.Watcher) - themeWatcher.Close() - } - - var err error - if themeWatcher, err = fsnotify.NewWatcher(); err != nil { - logging.LogErrorf("add theme file watcher for folder [%s] failed: %s", folder, err) - return - } - themeWatchers.Store(folder, themeWatcher) - - done := make(chan bool) - go func() { - for { - select { - case event, ok := <-themeWatcher.Events: - if !ok { - return - } - - //logging.LogInfof(event.String()) - if event.Op&fsnotify.Write == fsnotify.Write && (strings.HasSuffix(event.Name, "theme.css")) { - var themeName string - if themeName = isCurrentUseTheme(event.Name); "" == themeName { - break - } - - if strings.HasSuffix(event.Name, "theme.css") { - util.BroadcastByType("main", "refreshtheme", 0, "", map[string]interface{}{ - "theme": "/appearance/themes/" + themeName + "/theme.css?" + fmt.Sprintf("%d", time.Now().Unix()), - }) - break - } - } - case err, ok := <-themeWatcher.Errors: - if !ok { - return - } - logging.LogErrorf("watch theme file failed: %s", err) - } - } - }() - - //logging.LogInfof("add file watcher [%s]", folder) - if err := themeWatcher.Add(folder); err != nil { - logging.LogErrorf("add theme files watcher for folder [%s] failed: %s", folder, err) - } - <-done + Conf.m.Lock() + Conf.Appearance.Icons = icons + Conf.Appearance.IconVer = iconVer + Conf.m.Unlock() } func isCurrentUseTheme(themePath string) string { @@ -285,3 +202,22 @@ func isCurrentUseTheme(themePath string) string { } return "" } + +func broadcastRefreshThemeIfCurrent(themeCssPath string) { + if !strings.HasSuffix(themeCssPath, "theme.css") { + return + } + // 只处理主题根目录中的 theme.css + themeDir := filepath.Clean(filepath.Dir(themeCssPath)) + themesRoot := filepath.Clean(util.ThemesPath) + if themeDir != filepath.Join(themesRoot, filepath.Base(themeDir)) { + return + } + themeName := isCurrentUseTheme(themeCssPath) + if themeName == "" { + return + } + util.BroadcastByType("main", "refreshtheme", 0, "", map[string]interface{}{ + "theme": "/appearance/themes/" + themeName + "/theme.css?" + fmt.Sprintf("%d", time.Now().Unix()), + }) +} diff --git a/kernel/model/assets_watcher.go b/kernel/model/assets_watcher.go index 8adff63e5..781c2d041 100644 --- a/kernel/model/assets_watcher.go +++ b/kernel/model/assets_watcher.go @@ -37,23 +37,30 @@ func WatchAssets() { return } - go func() { - watchAssets() - }() + go watchAssets() } func watchAssets() { + CloseWatchAssets() assetsDir := filepath.Join(util.DataDir, "assets") - if nil != assetsWatcher { - assetsWatcher.Close() - } var err error - if assetsWatcher, err = fsnotify.NewWatcher(); err != nil { + assetsWatcher, err = fsnotify.NewWatcher() + if err != nil { logging.LogErrorf("add assets watcher for folder [%s] failed: %s", assetsDir, err) return } + if !gulu.File.IsDir(assetsDir) { + os.MkdirAll(assetsDir, 0755) + } + + if err = assetsWatcher.Add(assetsDir); err != nil { + logging.LogErrorf("add assets watcher for folder [%s] failed: %s", assetsDir, err) + CloseWatchAssets() + return + } + go func() { defer logging.Recover() @@ -95,19 +102,11 @@ func watchAssets() { } } }() - - if !gulu.File.IsDir(assetsDir) { - os.MkdirAll(assetsDir, 0755) - } - - if err = assetsWatcher.Add(assetsDir); err != nil { - logging.LogErrorf("add assets watcher for folder [%s] failed: %s", assetsDir, err) - } - //logging.LogInfof("added file watcher [%s]", assetsDir) } func CloseWatchAssets() { if nil != assetsWatcher { assetsWatcher.Close() + assetsWatcher = nil } } diff --git a/kernel/model/assets_watcher_darwin.go b/kernel/model/assets_watcher_darwin.go index 32e620e22..8d355af35 100644 --- a/kernel/model/assets_watcher_darwin.go +++ b/kernel/model/assets_watcher_darwin.go @@ -19,9 +19,11 @@ package model import ( + "os" "path/filepath" "time" + "github.com/88250/gulu" "github.com/radovskyb/watcher" "github.com/siyuan-note/logging" "github.com/siyuan-note/siyuan/kernel/cache" @@ -31,20 +33,31 @@ import ( var assetsWatcher *watcher.Watcher func WatchAssets() { - go func() { - watchAssets() - }() + if !isFileWatcherAvailable() { + return + } + + go watchAssets() } func watchAssets() { - if nil != assetsWatcher { - assetsWatcher.Close() - } - assetsWatcher = watcher.New() - + CloseWatchAssets() assetsDir := filepath.Join(util.DataDir, "assets") + assetsWatcher = watcher.New() + + if !gulu.File.IsDir(assetsDir) { + os.MkdirAll(assetsDir, 0755) + } + + if err := assetsWatcher.Add(assetsDir); err != nil { + logging.LogErrorf("add assets watcher for folder [%s] failed: %s", assetsDir, err) + return + } + go func() { + defer logging.Recover() + for { select { case event, ok := <-assetsWatcher.Event: @@ -75,13 +88,6 @@ func watchAssets() { } } }() - - if err := assetsWatcher.Add(assetsDir); err != nil { - logging.LogErrorf("add assets watcher for folder [%s] failed: %s", assetsDir, err) - return - } - - //logging.LogInfof("added file watcher [%s]", assetsDir) if err := assetsWatcher.Start(10 * time.Second); err != nil { logging.LogErrorf("start assets watcher for folder [%s] failed: %s", assetsDir, err) return @@ -91,5 +97,6 @@ func watchAssets() { func CloseWatchAssets() { if nil != assetsWatcher { assetsWatcher.Close() + assetsWatcher = nil } } diff --git a/kernel/model/bazzar.go b/kernel/model/bazzar.go index c873e5dc0..ac504919a 100644 --- a/kernel/model/bazzar.go +++ b/kernel/model/bazzar.go @@ -432,7 +432,7 @@ func InstalledThemes(keyword string) (ret []*bazaar.Theme) { } func InstallBazaarTheme(repoURL, repoHash, themeName string, mode int, update bool) error { - closeThemeWatchers() + CloseWatchThemes() installPath := filepath.Join(util.ThemesPath, themeName) err := bazaar.InstallTheme(repoURL, repoHash, installPath, Conf.System.ID) @@ -458,7 +458,7 @@ func InstallBazaarTheme(repoURL, repoHash, themeName string, mode int, update bo } func UninstallBazaarTheme(themeName string) error { - closeThemeWatchers() + CloseWatchThemes() installPath := filepath.Join(util.ThemesPath, themeName) err := bazaar.UninstallTheme(installPath) @@ -580,3 +580,8 @@ func getSearchKeywords(query string) (ret []string) { } return } + +// isBuiltInTheme 通过包名或目录名判断是否为内置主题 +func isBuiltInTheme(name string) bool { + return "daylight" == name || "midnight" == name +} diff --git a/kernel/model/emojis_watcher.go b/kernel/model/emojis_watcher.go index 8b23fd479..2974e2954 100644 --- a/kernel/model/emojis_watcher.go +++ b/kernel/model/emojis_watcher.go @@ -32,27 +32,34 @@ import ( var emojisWatcher *fsnotify.Watcher func WatchEmojis() { - if util.ContainerAndroid == util.Container || util.ContainerIOS == util.Container || util.ContainerHarmony == util.Container { + if !isFileWatcherAvailable() { return } - go func() { - watchEmojis() - }() + go watchEmojis() } func watchEmojis() { + CloseWatchEmojis() emojisDir := filepath.Join(util.DataDir, "emojis") - if nil != emojisWatcher { - emojisWatcher.Close() - } var err error - if emojisWatcher, err = fsnotify.NewWatcher(); err != nil { + emojisWatcher, err = fsnotify.NewWatcher() + if err != nil { logging.LogErrorf("add emojis watcher for folder [%s] failed: %s", emojisDir, err) return } + if !gulu.File.IsDir(emojisDir) { + os.MkdirAll(emojisDir, 0755) + } + + if err = emojisWatcher.Add(emojisDir); err != nil { + logging.LogErrorf("add emojis watcher for folder [%s] failed: %s", emojisDir, err) + CloseWatchEmojis() + return + } + go func() { defer logging.Recover() @@ -77,19 +84,11 @@ func watchEmojis() { } } }() - - if !gulu.File.IsDir(emojisDir) { - os.MkdirAll(emojisDir, 0755) - } - - if err = emojisWatcher.Add(emojisDir); err != nil { - logging.LogErrorf("add emojis watcher for folder [%s] failed: %s", emojisDir, err) - } - //logging.LogInfof("added file watcher [%s]", emojisDir) } func CloseWatchEmojis() { if nil != emojisWatcher { emojisWatcher.Close() + emojisWatcher = nil } } diff --git a/kernel/model/emojis_watcher_darwin.go b/kernel/model/emojis_watcher_darwin.go index 652b3c917..419180dfe 100644 --- a/kernel/model/emojis_watcher_darwin.go +++ b/kernel/model/emojis_watcher_darwin.go @@ -19,9 +19,11 @@ package model import ( + "os" "path/filepath" "time" + "github.com/88250/gulu" "github.com/radovskyb/watcher" "github.com/siyuan-note/logging" "github.com/siyuan-note/siyuan/kernel/util" @@ -30,20 +32,31 @@ import ( var emojisWatcher *watcher.Watcher func WatchEmojis() { - go func() { - watchEmojis() - }() + if !isFileWatcherAvailable() { + return + } + + go watchEmojis() } func watchEmojis() { - if nil != emojisWatcher { - emojisWatcher.Close() - } - emojisWatcher = watcher.New() - emojisDir := filepath.Join(util.DataDir, "emojis") + CloseWatchEmojis() + emojisWatcher = watcher.New() + + if !gulu.File.IsDir(emojisDir) { + os.MkdirAll(emojisDir, 0755) + } + + if err := emojisWatcher.Add(emojisDir); err != nil { + logging.LogErrorf("add emojis watcher for folder [%s] failed: %s", emojisDir, err) + return + } + go func() { + defer logging.Recover() + for { select { case _, ok := <-emojisWatcher.Event: @@ -61,13 +74,6 @@ func watchEmojis() { } } }() - - if err := emojisWatcher.Add(emojisDir); err != nil { - logging.LogErrorf("add emojis watcher for folder [%s] failed: %s", emojisDir, err) - return - } - - //logging.LogInfof("added file watcher [%s]", emojisDir) if err := emojisWatcher.Start(10 * time.Second); err != nil { logging.LogErrorf("start emojis watcher for folder [%s] failed: %s", emojisDir, err) return @@ -77,5 +83,6 @@ func watchEmojis() { func CloseWatchEmojis() { if nil != emojisWatcher { emojisWatcher.Close() + emojisWatcher = nil } } diff --git a/kernel/model/themes_watcher.go b/kernel/model/themes_watcher.go new file mode 100644 index 000000000..2e98dc78d --- /dev/null +++ b/kernel/model/themes_watcher.go @@ -0,0 +1,161 @@ +// SiYuan - Refactor your thinking +// Copyright (c) 2020-present, b3log.org +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//go:build !darwin + +package model + +import ( + "os" + "path/filepath" + "time" + + "github.com/88250/gulu" + "github.com/fsnotify/fsnotify" + "github.com/siyuan-note/logging" + "github.com/siyuan-note/siyuan/kernel/util" +) + +var themesWatcher *fsnotify.Watcher + +func WatchThemes() { + if !isFileWatcherAvailable() { + return + } + + go watchThemes() +} + +func watchThemes() { + CloseWatchThemes() + themesDir := util.ThemesPath + + var err error + themesWatcher, err = fsnotify.NewWatcher() + if err != nil { + logging.LogErrorf("add themes watcher for folder [%s] failed: %s", themesDir, err) + return + } + + if !gulu.File.IsDir(themesDir) { + os.MkdirAll(themesDir, 0755) + } + + if err = themesWatcher.Add(themesDir); err != nil { + logging.LogErrorf("add themes root watcher for folder [%s] failed: %s", themesDir, err) + CloseWatchThemes() + return + } + + // 为每个子目录添加监听,以便收到 theme.css 的变更 + addThemesSubdirs(themesWatcher, themesDir) + + go func() { + defer logging.Recover() + + var ( + timer *time.Timer + lastEvent fsnotify.Event + ) + timer = time.NewTimer(100 * time.Millisecond) + <-timer.C // timer should be expired at first + + for { + select { + case event, ok := <-themesWatcher.Events: + if !ok { + return + } + + // 新目录创建时加入监听 + if event.Op&fsnotify.Create == fsnotify.Create { + if isThemesDirectSubdir(event.Name) { + if addErr := themesWatcher.Add(event.Name); addErr != nil { + logging.LogWarnf("add themes watcher for new folder [%s] failed: %s", event.Name, addErr) + } + } + } + + lastEvent = event + timer.Reset(time.Millisecond * 100) + case err, ok := <-themesWatcher.Errors: + if !ok { + return + } + logging.LogErrorf("watch themes failed: %s", err) + case <-timer.C: + handleThemesEvent(lastEvent) + } + } + }() +} + +// addThemesSubdirs 为 themes 下每个子目录添加监听 +func addThemesSubdirs(w *fsnotify.Watcher, themesDir string) { + entries, err := os.ReadDir(themesDir) + if err != nil { + logging.LogErrorf("read themes folder failed: %s", err) + return + } + for _, e := range entries { + if !util.IsDirRegularOrSymlink(e) { + continue + } + subdir := filepath.Join(themesDir, e.Name()) + if addErr := w.Add(subdir); addErr != nil { + logging.LogWarnf("add themes watcher for folder [%s] failed: %s", subdir, addErr) + } + } +} + +// isThemesDirectSubdir 判断 path 是否为 themes 下的直接子目录 +func isThemesDirectSubdir(path string) bool { + if !gulu.File.IsDir(path) { + return false + } + rel, err := filepath.Rel(util.ThemesPath, path) + if err != nil { + return false + } + if filepath.Base(path) != rel { + return false + } + entries, err := os.ReadDir(util.ThemesPath) + if err != nil { + return false + } + name := filepath.Base(path) + for _, e := range entries { + if e.Name() == name { + return util.IsDirRegularOrSymlink(e) + } + } + return false +} + +func handleThemesEvent(event fsnotify.Event) { + if event.Op&fsnotify.Write != fsnotify.Write { + return + } + broadcastRefreshThemeIfCurrent(event.Name) +} + +func CloseWatchThemes() { + if nil != themesWatcher { + themesWatcher.Close() + themesWatcher = nil + } +} diff --git a/kernel/model/themes_watcher_darwin.go b/kernel/model/themes_watcher_darwin.go new file mode 100644 index 000000000..332b96030 --- /dev/null +++ b/kernel/model/themes_watcher_darwin.go @@ -0,0 +1,156 @@ +// SiYuan - Refactor your thinking +// Copyright (c) 2020-present, b3log.org +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//go:build darwin + +package model + +import ( + "os" + "path/filepath" + "strings" + "time" + + "github.com/88250/gulu" + "github.com/radovskyb/watcher" + "github.com/siyuan-note/logging" + "github.com/siyuan-note/siyuan/kernel/util" +) + +var themesWatcher *watcher.Watcher + +func WatchThemes() { + if !isFileWatcherAvailable() { + return + } + + go watchThemes() +} + +func watchThemes() { + CloseWatchThemes() + themesDir := util.ThemesPath + + themesWatcher = watcher.New() + + if !gulu.File.IsDir(themesDir) { + os.MkdirAll(themesDir, 0755) + } + + if err := themesWatcher.Add(themesDir); err != nil { + logging.LogErrorf("add themes watcher for folder [%s] failed: %s", themesDir, err) + return + } + + // 为每个子目录添加监听,以便收到 theme.css 的变更 + addThemesSubdirs(themesWatcher, themesDir) + + go func() { + defer logging.Recover() + + for { + select { + case event, ok := <-themesWatcher.Event: + if !ok { + return + } + + // 新目录创建时加入监听 + if watcher.Create == event.Op { + if isThemesDirectSubdir(event.Path) { + if addErr := themesWatcher.Add(event.Path); addErr != nil { + logging.LogWarnf("add themes watcher for new folder [%s] failed: %s", event.Path, addErr) + } + } + } + + handleThemesEvent(event) + case err, ok := <-themesWatcher.Error: + if !ok { + return + } + logging.LogErrorf("watch themes failed: %s", err) + case <-themesWatcher.Closed: + return + } + } + }() + + if err := themesWatcher.Start(10 * time.Second); err != nil { + logging.LogErrorf("start themes watcher for folder [%s] failed: %s", themesDir, err) + return + } +} + +// addThemesSubdirs 为 themes 下每个子目录添加监听 +func addThemesSubdirs(w *watcher.Watcher, themesDir string) { + entries, err := os.ReadDir(themesDir) + if err != nil { + logging.LogErrorf("read themes folder failed: %s", err) + return + } + for _, e := range entries { + if !util.IsDirRegularOrSymlink(e) { + continue + } + subdir := filepath.Join(themesDir, e.Name()) + if addErr := w.Add(subdir); addErr != nil { + logging.LogWarnf("add themes watcher for folder [%s] failed: %s", subdir, addErr) + } + } +} + +// isThemesDirectSubdir 判断 path 是否为 themes 下的直接子目录 +func isThemesDirectSubdir(path string) bool { + if !gulu.File.IsDir(path) { + return false + } + rel, err := filepath.Rel(util.ThemesPath, path) + if err != nil { + return false + } + if filepath.Base(path) != rel { + return false + } + entries, err := os.ReadDir(util.ThemesPath) + if err != nil { + return false + } + name := filepath.Base(path) + for _, e := range entries { + if e.Name() == name { + return util.IsDirRegularOrSymlink(e) + } + } + return false +} + +func handleThemesEvent(event watcher.Event) { + if watcher.Write != event.Op { + return + } + if !strings.HasSuffix(event.Path, "theme.css") { + return + } + broadcastRefreshThemeIfCurrent(event.Path) +} + +func CloseWatchThemes() { + if nil != themesWatcher { + themesWatcher.Close() + themesWatcher = nil + } +} From 5e0d1e64de71b2c1a9c85745780b80ae789530a3 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:47:44 +0800 Subject: [PATCH 4/7] :recycle: Normalize bazaar naming (#17136) --- kernel/model/{bazzar.go => bazaar.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename kernel/model/{bazzar.go => bazaar.go} (100%) diff --git a/kernel/model/bazzar.go b/kernel/model/bazaar.go similarity index 100% rename from kernel/model/bazzar.go rename to kernel/model/bazaar.go From 487e27cb000e3d0d45b05e31cbed04189d10f687 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:11:44 +0800 Subject: [PATCH 5/7] :recycle: refactor reloadPlugin (#17137) --- app/src/config/bazaar.ts | 2 +- app/src/plugin/loader.ts | 18 +++---- kernel/api/petal.go | 6 +-- kernel/model/bazaar.go | 2 +- kernel/model/push_reload.go | 101 ++++++++++++------------------------ kernel/model/repository.go | 14 ++--- 6 files changed, 54 insertions(+), 89 deletions(-) diff --git a/app/src/config/bazaar.ts b/app/src/config/bazaar.ts index 153567568..0ba9a74f3 100644 --- a/app/src/config/bazaar.ts +++ b/app/src/config/bazaar.ts @@ -771,7 +771,7 @@ type="checkbox"> app.plugins.find((item: Plugin) => { if (item.name === dataObj.name) { reloadPlugin(app, { - upsertCodePlugins: [dataObj.name], + reloadPlugins: [dataObj.name], }); return true; } diff --git a/app/src/plugin/loader.ts b/app/src/plugin/loader.ts index d3dfc2f77..fbe746232 100644 --- a/app/src/plugin/loader.ts +++ b/app/src/plugin/loader.ts @@ -223,12 +223,12 @@ export const afterLoadPlugin = (plugin: Plugin) => { }; export const reloadPlugin = async (app: App, data: { - upsertCodePlugins?: string[], - upsertDataPlugins?: string[], - unloadPlugins?: string[], - uninstallPlugins?: string[], + uninstallPlugins?: string[], // 插件卸载 + unloadPlugins?: string[], // 插件禁用 + reloadPlugins?: string[], // 插件启用,或插件代码变更 + dataChangePlugins?: string[], // 插件存储数据变更 } = {}) => { - const {upsertCodePlugins = [], upsertDataPlugins = [], unloadPlugins = [], uninstallPlugins = []} = data; + const {uninstallPlugins = [], unloadPlugins = [], reloadPlugins = [], dataChangePlugins = []} = data; // 禁用 unloadPlugins.forEach((item) => { uninstall(app, item, true); @@ -237,12 +237,12 @@ export const reloadPlugin = async (app: App, data: { uninstallPlugins.forEach((item) => { uninstall(app, item, false); }); - upsertCodePlugins.forEach((item) => { + reloadPlugins.forEach((item) => { uninstall(app, item, true); }); - loadPlugins(app, upsertCodePlugins, false).then(() => { + loadPlugins(app, reloadPlugins, false).then(() => { app.plugins.forEach(item => { - if (upsertCodePlugins.includes(item.name)) { + if (reloadPlugins.includes(item.name)) { afterLoadPlugin(item); getAllEditor().forEach(editor => { editor.protyle.toolbar.update(editor.protyle); @@ -251,7 +251,7 @@ export const reloadPlugin = async (app: App, data: { }); }); app.plugins.forEach(item => { - if (upsertDataPlugins.includes(item.name)) { + if (dataChangePlugins.includes(item.name)) { try { item.onDataChanged(); } catch (e) { diff --git a/kernel/api/petal.go b/kernel/api/petal.go index b9e45a488..bf8397f19 100644 --- a/kernel/api/petal.go +++ b/kernel/api/petal.go @@ -67,10 +67,10 @@ func setPetalEnabled(c *gin.Context) { app = arg["app"].(string) } if enabled { - upsertPluginCodeSet := hashset.New(packageName) - model.PushReloadPlugin(upsertPluginCodeSet, nil, nil, nil, app) + reloadPluginSet := hashset.New(packageName) + model.PushReloadPlugin(nil, nil, reloadPluginSet, nil, app) } else { unloadPluginSet := hashset.New(packageName) - model.PushReloadPlugin(nil, nil, unloadPluginSet, nil, app) + model.PushReloadPlugin(nil, unloadPluginSet, nil, nil, app) } } diff --git a/kernel/model/bazaar.go b/kernel/model/bazaar.go index ac504919a..7eaf4e3ae 100644 --- a/kernel/model/bazaar.go +++ b/kernel/model/bazaar.go @@ -267,7 +267,7 @@ func UninstallBazaarPlugin(pluginName, frontend string) error { savePetals(petals) uninstallPluginSet := hashset.New(pluginName) - PushReloadPlugin(nil, nil, nil, uninstallPluginSet, "") + PushReloadPlugin(uninstallPluginSet, nil, nil, nil, "") return nil } diff --git a/kernel/model/push_reload.go b/kernel/model/push_reload.go index 29180556a..bc0cd8b56 100644 --- a/kernel/model/push_reload.go +++ b/kernel/model/push_reload.go @@ -43,85 +43,50 @@ func PushReloadSnippet(snippet *conf.Snpt) { util.BroadcastByType("main", "setSnippet", 0, "", snippet) } -func PushReloadPlugin(upsertCodePluginSet, upsertDataPluginSet, unloadPluginNameSet, uninstallPluginNameSet *hashset.Set, excludeApp string) { - // 集合去重 - if nil != uninstallPluginNameSet { - for _, n := range uninstallPluginNameSet.Values() { - pluginName := n.(string) - if nil != upsertCodePluginSet { - upsertCodePluginSet.Remove(pluginName) - } - if nil != upsertDataPluginSet { - upsertDataPluginSet.Remove(pluginName) - } - if nil != unloadPluginNameSet { - unloadPluginNameSet.Remove(pluginName) +func PushReloadPlugin(uninstallPluginNameSet, unloadPluginNameSet, reloadPluginSet, dataChangePluginSet *hashset.Set, excludeApp string) { + // 按优先级从高到低排列,同一插件只保留在优先级最高的集合中 + orderedSets := []*hashset.Set{uninstallPluginNameSet, unloadPluginNameSet, reloadPluginSet, dataChangePluginSet} + slices := make([][]string, len(orderedSets)) + // 按顺序遍历所有集合 + for i, set := range orderedSets { + if nil != set { + // 遍历当前集合的所有插件名称 + for _, n := range set.Values() { + name := n.(string) + // 将该插件从所有后续集合中移除 + for _, lowerSet := range orderedSets[i+1:] { + if nil != lowerSet { + lowerSet.Remove(name) + } + } } } - } - if nil != unloadPluginNameSet { - for _, n := range unloadPluginNameSet.Values() { - pluginName := n.(string) - if nil != upsertCodePluginSet { - upsertCodePluginSet.Remove(pluginName) - } - if nil != upsertDataPluginSet { - upsertDataPluginSet.Remove(pluginName) - } - } - } - if nil != upsertCodePluginSet { - for _, n := range upsertCodePluginSet.Values() { - pluginName := n.(string) - if nil != upsertDataPluginSet { - upsertDataPluginSet.Remove(pluginName) + + // 将当前集合转换为字符串切片 + if nil == set { + slices[i] = []string{} + } else { + strs := make([]string, 0, set.Size()) + for _, n := range set.Values() { + strs = append(strs, n.(string)) } + slices[i] = strs } } - upsertCodePlugins, upsertDataPlugins, unloadPlugins, uninstallPlugins := []string{}, []string{}, []string{}, []string{} - if nil != upsertCodePluginSet { - for _, n := range upsertCodePluginSet.Values() { - upsertCodePlugins = append(upsertCodePlugins, n.(string)) - } - } - if nil != upsertDataPluginSet { - for _, n := range upsertDataPluginSet.Values() { - upsertDataPlugins = append(upsertDataPlugins, n.(string)) - } - } - if nil != unloadPluginNameSet { - for _, n := range unloadPluginNameSet.Values() { - unloadPlugins = append(unloadPlugins, n.(string)) - } - } - if nil != uninstallPluginNameSet { - for _, n := range uninstallPluginNameSet.Values() { - uninstallPlugins = append(uninstallPlugins, n.(string)) - } + logging.LogInfof("reload plugins, uninstalls=%v, unloads=%v, reloads=%v, dataChanges=%v", slices[0], slices[1], slices[2], slices[3]) + payload := map[string]interface{}{ + "uninstallPlugins": slices[0], // 插件卸载 + "unloadPlugins": slices[1], // 插件禁用 + "reloadPlugins": slices[2], // 插件启用,或插件代码变更 + "dataChangePlugins": slices[3], // 插件存储数据变更 } - pushReloadPlugin0(upsertCodePlugins, upsertDataPlugins, unloadPlugins, uninstallPlugins, excludeApp) -} - -func pushReloadPlugin0(upsertCodePlugins, upsertDataPlugins, unloadPlugins, uninstallPlugins []string, excludeApp string) { - logging.LogInfof("reload plugins [codeChanges=%v, dataChanges=%v, unloads=%v, uninstalls=%v]", upsertCodePlugins, upsertDataPlugins, unloadPlugins, uninstallPlugins) if "" == excludeApp { - util.BroadcastByType("main", "reloadPlugin", 0, "", map[string]interface{}{ - "upsertCodePlugins": upsertCodePlugins, - "upsertDataPlugins": upsertDataPlugins, - "unloadPlugins": unloadPlugins, - "uninstallPlugins": uninstallPlugins, - }) + util.BroadcastByType("main", "reloadPlugin", 0, "", payload) return } - - util.BroadcastByTypeAndExcludeApp(excludeApp, "main", "reloadPlugin", 0, "", map[string]interface{}{ - "upsertCodePlugins": upsertCodePlugins, - "upsertDataPlugins": upsertDataPlugins, - "unloadPlugins": unloadPlugins, - "uninstallPlugins": uninstallPlugins, - }) + util.BroadcastByTypeAndExcludeApp(excludeApp, "main", "reloadPlugin", 0, "", payload) } func refreshDocInfo(tree *parse.Tree) { diff --git a/kernel/model/repository.go b/kernel/model/repository.go index 6e44edbee..52d2c624d 100644 --- a/kernel/model/repository.go +++ b/kernel/model/repository.go @@ -1596,8 +1596,8 @@ func processSyncMergeResult(exit, byHand bool, mergeResult *dejavu.MergeResult, var upsertTrees int // 可能需要重新加载部分功能 var needReloadFlashcard, needReloadOcrTexts, needReloadPlugin, needReloadSnippet bool - upsertCodePluginSet := hashset.New() // 插件代码变更 data/plugins/ - upsertDataPluginSet := hashset.New() // 插件存储数据变更 data/storage/petal/ + reloadPluginSet := hashset.New() // 插件代码变更 data/plugins/ + dataChangePluginSet := hashset.New() // 插件存储数据变更 data/storage/petal/ needUnindexBoxes, needIndexBoxes := map[string]bool{}, map[string]bool{} for _, file := range mergeResult.Upserts { upserts = append(upserts, file.Path) @@ -1620,7 +1620,7 @@ func processSyncMergeResult(exit, byHand bool, mergeResult *dejavu.MergeResult, needReloadPlugin = true if parts := strings.Split(file.Path, "/"); 3 < len(parts) { if pluginName := parts[3]; "petals.json" != pluginName { - upsertDataPluginSet.Add(pluginName) + dataChangePluginSet.Add(pluginName) } } } @@ -1628,7 +1628,7 @@ func processSyncMergeResult(exit, byHand bool, mergeResult *dejavu.MergeResult, if strings.HasPrefix(file.Path, "/plugins/") { if parts := strings.Split(file.Path, "/"); 2 < len(parts) { needReloadPlugin = true - upsertCodePluginSet.Add(parts[2]) + reloadPluginSet.Add(parts[2]) } } @@ -1667,7 +1667,7 @@ func processSyncMergeResult(exit, byHand bool, mergeResult *dejavu.MergeResult, needReloadPlugin = true if parts := strings.Split(file.Path, "/"); 3 < len(parts) { if pluginName := parts[3]; "petals.json" != pluginName { - upsertDataPluginSet.Add(pluginName) + dataChangePluginSet.Add(pluginName) } } } @@ -1698,7 +1698,7 @@ func processSyncMergeResult(exit, byHand bool, mergeResult *dejavu.MergeResult, for _, upsertPetal := range mergeResult.UpsertPetals { needReloadPlugin = true - upsertCodePluginSet.Add(upsertPetal) + reloadPluginSet.Add(upsertPetal) } for _, removePetal := range mergeResult.RemovePetals { needReloadPlugin = true @@ -1715,7 +1715,7 @@ func processSyncMergeResult(exit, byHand bool, mergeResult *dejavu.MergeResult, } if needReloadPlugin { - PushReloadPlugin(upsertCodePluginSet, upsertDataPluginSet, unloadPluginSet, uninstallPluginSet, "") + PushReloadPlugin(uninstallPluginSet, unloadPluginSet, reloadPluginSet, dataChangePluginSet, "") } if needReloadSnippet { From 51295adb4b8fbc4cd12cff4b37f27fa63d842843 Mon Sep 17 00:00:00 2001 From: Daniel <845765@qq.com> Date: Thu, 5 Mar 2026 10:13:57 +0800 Subject: [PATCH 6/7] :art: Support sending notifications on Android https://github.com/siyuan-note/siyuan/issues/17114 Signed-off-by: Daniel <845765@qq.com> --- app/src/mobile/util/onMessage.ts | 2 +- app/src/types/index.d.ts | 2 +- kernel/api/notification.go | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/mobile/util/onMessage.ts b/app/src/mobile/util/onMessage.ts index 196759437..3b47e4020 100644 --- a/app/src/mobile/util/onMessage.ts +++ b/app/src/mobile/util/onMessage.ts @@ -26,7 +26,7 @@ export const onMessage = (app: App, data: IWebSocketData) => { break; case "sendDeviceNotification": if (window.JSAndroid.sendNotification) { - window.JSAndroid.sendNotification(data.data.title, data.data.body, data.data.delayInSeconds); + window.JSAndroid.sendNotification(data.data.channel, data.data.title, data.data.body, data.data.delayInSeconds); } break; case "backgroundtask": diff --git a/app/src/types/index.d.ts b/app/src/types/index.d.ts index 84f4a8c1f..9348ebd9a 100644 --- a/app/src/types/index.d.ts +++ b/app/src/types/index.d.ts @@ -255,7 +255,7 @@ interface Window { getScreenWidthPx(): number exit(): void setWebViewFocusable(enable: boolean): void - sendNotification(title: string, body: string, delayInSeconds: number): void + sendNotification(channel: string, title: string, body: string, delayInSeconds: number): void }; JSHarmony: { showKeyboard(): void diff --git a/kernel/api/notification.go b/kernel/api/notification.go index f71192252..4b0a5a48c 100644 --- a/kernel/api/notification.go +++ b/kernel/api/notification.go @@ -40,6 +40,13 @@ func sendDeviceNotification(c *gin.Context) { return } + var channel string + if nil != arg["channel"] { + channel = strings.TrimSpace(arg["channel"].(string)) + } else { + channel = "SiYuan Notifications" + } + var title string if nil != arg["title"] { title = strings.TrimSpace(arg["title"].(string)) @@ -66,6 +73,7 @@ func sendDeviceNotification(c *gin.Context) { } util.BroadcastByType("main", "sendDeviceNotification", 0, "", map[string]interface{}{ + "channel": channel, "title": title, "body": body, "delayInSeconds": delayInSeconds, From 5d98257e51eb401933c776f130d273fbe523d768 Mon Sep 17 00:00:00 2001 From: Daniel <845765@qq.com> Date: Thu, 5 Mar 2026 10:54:54 +0800 Subject: [PATCH 7/7] :art: Support sending notifications on HarmonyOS https://github.com/siyuan-note/siyuan/issues/17125 Signed-off-by: Daniel <845765@qq.com> --- app/src/mobile/util/onMessage.ts | 4 ++++ app/src/types/index.d.ts | 1 + kernel/api/notification.go | 4 ++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/mobile/util/onMessage.ts b/app/src/mobile/util/onMessage.ts index 3b47e4020..dabaf88dd 100644 --- a/app/src/mobile/util/onMessage.ts +++ b/app/src/mobile/util/onMessage.ts @@ -28,6 +28,10 @@ export const onMessage = (app: App, data: IWebSocketData) => { if (window.JSAndroid.sendNotification) { window.JSAndroid.sendNotification(data.data.channel, data.data.title, data.data.body, data.data.delayInSeconds); } + if (window.JSHarmony.sendNotification) { + window.JSHarmony.sendNotification(data.data.channel, data.data.title, data.data.body, data.data.delayInSeconds); + } + break; case "backgroundtask": if (!document.querySelector("#keyboardToolbar").classList.contains("fn__none") || diff --git a/app/src/types/index.d.ts b/app/src/types/index.d.ts index 9348ebd9a..78336d690 100644 --- a/app/src/types/index.d.ts +++ b/app/src/types/index.d.ts @@ -274,6 +274,7 @@ interface Window { getScreenWidthPx(): number exit(): void setWebViewFocusable(enable: boolean): void + sendNotification(channel: string, title: string, body: string, delayInSeconds: number): void }; Protyle: import("../protyle/method").default; diff --git a/kernel/api/notification.go b/kernel/api/notification.go index 4b0a5a48c..9922396e2 100644 --- a/kernel/api/notification.go +++ b/kernel/api/notification.go @@ -34,9 +34,9 @@ func sendDeviceNotification(c *gin.Context) { return } - if util.ContainerAndroid != util.Container { + if util.ContainerAndroid != util.Container && util.ContainerHarmony != util.Container { ret.Code = -1 - ret.Msg = "Just support Android" + ret.Msg = "Just support Android and HarmonyOS" return }