diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 79522326..0dbd0643 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -11,6 +11,8 @@ categories: - 'fix' - 'bugfix' - 'bug' + - title: '๐Ÿงช Tests' + label: 'test' - title: '๐Ÿงฐ Maintenance' label: 'chore' - title: '๐Ÿ“– Document' diff --git a/pkg/base/model.go b/pkg/base/model.go index b27facfd..697df227 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -9,6 +9,15 @@ import ( type Request struct { URL string `json:"url"` Extra any `json:"extra"` + // Labels is used to mark the download task + Labels map[string]string `json:"labels"` +} + +func (r *Request) Validate() error { + if r.URL == "" { + return fmt.Errorf("invalid request url") + } + return nil } // Resource download resource @@ -36,7 +45,6 @@ func (r *Resource) Validate() error { } } return nil - } func (r *Resource) CalcSize() { diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index f49ffeb3..fffc58c1 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -429,6 +429,9 @@ func (d *Downloader) emit(eventKey EventKey, task *Task, errs ...error) { Task: task, Err: err, }) + if eventKey == EventKeyError { + d.emit(EventKeyFinally, task, err) + } } } @@ -494,8 +497,8 @@ func (d *Downloader) watch(task *Task) { task.Status = base.DownloadStatusDone d.storage.Put(bucketTask, task.ID, task.clone()) d.emit(EventKeyDone, task) + d.emit(EventKeyFinally, task, err) } - d.emit(EventKeyFinally, task, err) if task.Status == EventKeyDone { d.notifyRunning() } @@ -622,6 +625,8 @@ func (d *Downloader) doPauseAll() (err error) { func (d *Downloader) start(task *Task) error { task.lock.Lock() defer task.lock.Unlock() + d.triggerOnStart(task) + isCreate := task.Status == base.DownloadStatusReady task.Status = base.DownloadStatusRunning if task.Meta.Res == nil { diff --git a/pkg/download/extension.go b/pkg/download/extension.go index c5622f90..f996aff1 100644 --- a/pkg/download/extension.go +++ b/pkg/download/extension.go @@ -33,6 +33,7 @@ type ActivationEvent string const ( EventOnResolve ActivationEvent = "onResolve" + EventOnStart ActivationEvent = "onStart" //EventOnError ActivationEvent = "onError" //EventOnDone ActivationEvent = "onDone" ) @@ -47,7 +48,7 @@ func (d *Downloader) InstallExtensionByFolder(path string, devMode bool) (*Exten return nil, err } - // if dev mode, don't copy the extension to the extensions' directory + // if dev mode, don't copy to the extensions' directory if devMode { ext.DevMode = true ext.DevPath, _ = filepath.Abs(path) @@ -229,23 +230,71 @@ func (d *Downloader) parseExtensionByPath(path string) (*Extension, error) { } func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) { + doTrigger(d, + EventOnResolve, + req, + &OnResolveContext{ + Req: req, + }, + func(ext *Extension, gopeed *Instance, ctx *OnResolveContext) { + // Validate resource structure + if ctx.Res != nil && len(ctx.Res.Files) > 0 { + if err := ctx.Res.Validate(); err != nil { + gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] resource invalid", ext.buildIdentity()) + return + } + ctx.Res.CalcSize() + } + res = ctx.Res + }, + ) + return +} + +func (d *Downloader) triggerOnStart(task *Task) { + doTrigger(d, + EventOnStart, + task.Meta.Req, + &OnStartContext{ + Task: task.clone(), + }, + func(ext *Extension, gopeed *Instance, ctx *OnStartContext) { + // Validate request structure + if ctx.Task.Meta.Req != nil { + if err := ctx.Task.Meta.Req.Validate(); err != nil { + gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] request invalid", ext.buildIdentity()) + return + } + // Modify real task request + task.Meta.Req = ctx.Task.Meta.Req + } + }, + ) + return +} + +func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, ctx T, handler func(ext *Extension, gopeed *Instance, ctx T)) { // init extension global object gopeed := &Instance{ Events: make(InstanceEvents), } - ctx := &Context{ - Req: req, - } var err error for _, ext := range d.extensions { if ext.Disabled { continue } for _, script := range ext.Scripts { - if script.match(EventOnResolve, req.URL) { + if script.match(event, req) { + gopeed.Info = NewExtensionInfo(ext) gopeed.Logger = newInstanceLogger(ext, d.ExtensionLogger) + gopeed.Settings = parseSettings(ext.Settings) + gopeed.Storage = &ContextStorage{ + storage: d.storage, + identity: ext.buildIdentity(), + } scriptFilePath := filepath.Join(d.ExtensionPath(ext), script.Entry) if _, err = os.Stat(scriptFilePath); os.IsNotExist(err) { + gopeed.Logger.logger.Error().Err(err).Msgf("[%s] script file not exist", ext.buildIdentity()) continue } func() { @@ -256,17 +305,16 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) { return } defer scriptFile.Close() - ctx.Settings = parseSettings(ext.Settings) - ctx.Storage = &ContextStorage{ - storage: d.storage, - identity: ext.buildIdentity(), - } var scriptBuf []byte scriptBuf, err = io.ReadAll(scriptFile) if err != nil { gopeed.Logger.logger.Error().Err(err).Msgf("[%s] read script file failed", ext.buildIdentity()) return } + // Init request labels + if req.Labels == nil { + req.Labels = make(map[string]string) + } engine := engine.NewEngine() defer engine.Close() err = engine.Runtime.Set("gopeed", gopeed) @@ -279,21 +327,13 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) { gopeed.Logger.logger.Error().Err(err).Msgf("[%s] run script failed", ext.buildIdentity()) return } - if fn, ok := gopeed.Events[EventOnResolve]; ok { + if fn, ok := gopeed.Events[event]; ok { _, err = engine.CallFunction(fn, ctx) if err != nil { - gopeed.Logger.logger.Error().Err(err).Msgf("[%s] call function failed: %s", EventOnResolve, ext.buildIdentity()) + gopeed.Logger.logger.Error().Err(err).Msgf("[%s] call function failed: %s", ext.buildIdentity(), event) return } - // calculate resource total size - if ctx.Res != nil && len(ctx.Res.Files) > 0 { - if err = ctx.Res.Validate(); err != nil { - gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] resource invalid", ext.buildIdentity()) - return - } - ctx.Res.CalcSize() - } - res = ctx.Res + handler(ext, gopeed, ctx) } }() } @@ -396,30 +436,46 @@ type Repository struct { type Script struct { // Event active event name Event string `json:"event"` - // Matches match request url pattern list - Matches []string `json:"matches"` + // Match rules + Match *Match `json:"match"` // Entry js script file path Entry string `json:"entry"` } -func (s *Script) match(event ActivationEvent, url string) bool { +func (s *Script) match(event ActivationEvent, req *base.Request) bool { if s.Event == "" { return false } if s.Event != string(event) { return false } - if len(s.Matches) == 0 { + if s.Match == nil || (len(s.Match.Urls) == 0 && len(s.Match.Labels) == 0) { return false } - for _, match := range s.Matches { - if util.Match(match, url) { + + // match url + for _, url := range s.Match.Urls { + if util.Match(url, req.URL) { + return true + } + } + + // match label + for _, label := range s.Match.Labels { + if _, ok := req.Labels[label]; ok { return true } } return false } +type Match struct { + // Urls match expression, refer to https://developer.chrome.com/docs/extensions/mv3/match_patterns/ + Urls []string `json:"urls"` + // Labels match request labels + Labels []string `json:"labels"` +} + type SettingType string const ( @@ -446,10 +502,13 @@ type Option struct { Value any `json:"value"` } -// Instance inject to js context +// Instance inject to js context when extension script is activated type Instance struct { - Events InstanceEvents `json:"events"` - Logger *InstanceLogger `json:"logger"` + Events InstanceEvents `json:"events"` + Info *ExtensionInfo `json:"info"` + Logger *InstanceLogger `json:"logger"` + Settings map[string]any `json:"settings"` + Storage *ContextStorage `json:"storage"` } type InstanceEvents map[ActivationEvent]goja.Callable @@ -462,6 +521,10 @@ func (h InstanceEvents) OnResolve(fn goja.Callable) { h.register(EventOnResolve, fn) } +func (h InstanceEvents) OnStart(fn goja.Callable) { + h.register(EventOnStart, fn) +} + //func (h InstanceEvents) OnError(fn goja.Callable) { // h.register(HookEventOnError, fn) //} @@ -470,6 +533,24 @@ func (h InstanceEvents) OnResolve(fn goja.Callable) { // h.register(HookEventOnDone, fn) //} +type ExtensionInfo struct { + Identity string `json:"identity"` + Name string `json:"name"` + Author string `json:"author"` + Title string `json:"title"` + Version string `json:"version"` +} + +func NewExtensionInfo(ext *Extension) *ExtensionInfo { + return &ExtensionInfo{ + Identity: ext.buildIdentity(), + Name: ext.Name, + Author: ext.Author, + Title: ext.Title, + Version: ext.Version, + } +} + type InstanceLogger struct { identity string devMode bool @@ -510,11 +591,13 @@ func newInstanceLogger(extension *Extension, logger *logger.Logger) *InstanceLog } } -type Context struct { - Req *base.Request `json:"req"` - Res *base.Resource `json:"res"` - Settings map[string]any `json:"settings"` - Storage *ContextStorage `json:"storage"` +type OnResolveContext struct { + Req *base.Request `json:"req"` + Res *base.Resource `json:"res"` +} + +type OnStartContext struct { + Task *Task `json:"task"` } func parseSettings(settings []*Setting) map[string]any { diff --git a/pkg/download/extension_test.go b/pkg/download/extension_test.go index 502220c6..6211e647 100644 --- a/pkg/download/extension_test.go +++ b/pkg/download/extension_test.go @@ -1,10 +1,12 @@ package download import ( + "errors" "github.com/GopeedLab/gopeed/internal/logger" "github.com/GopeedLab/gopeed/pkg/base" "os" "testing" + "time" ) func TestDownloader_InstallExtensionByFolder(t *testing.T) { @@ -132,20 +134,52 @@ func TestDownloader_UpgradeExtension(t *testing.T) { }) } -func TestDownloader_ResolveWithExtra(t *testing.T) { - setupDownloader(func(downloader *Downloader) { - if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/extra", false); err != nil { - t.Fatal(err) - } - rr, err := downloader.Resolve(&base.Request{ - URL: "https://github.com/test", +func TestDownloader_ExtensionByOnStart(t *testing.T) { + downloadAndCheck := func(req *base.Request) { + setupDownloader(func(downloader *Downloader) { + if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/on_start", false); err != nil { + t.Fatal(err) + } + errCh := make(chan error, 1) + downloader.Listener(func(event *Event) { + if event.Key == EventKeyFinally { + errCh <- event.Err + } + }) + id, err := downloader.CreateDirect(req, nil) + if err != nil { + t.Fatal(err) + } + select { + case err = <-errCh: + break + case <-time.After(time.Second * 10): + err = errors.New("timeout") + } + if err != nil { + panic("extension on start download error: " + err.Error()) + } + task := downloader.GetTask(id) + if task.Meta.Req.URL != "https://github.com" { + panic("extension on start modify url error") + } + if task.Meta.Req.Labels["modified"] != "true" { + panic("extension on start modify label error") + } }) - if err != nil { - t.Fatal(err) - } - if len(rr.Res.Files) != 2 { - t.Fatal("resolve error") - } + } + + // url match + downloadAndCheck(&base.Request{ + URL: "https://github.com/gopeed/test/404", + }) + + // label match + downloadAndCheck(&base.Request{ + URL: "https://xxx.com", + Labels: map[string]string{ + "test": "true", + }, }) } diff --git a/pkg/download/testdata/extensions/basic/index.js b/pkg/download/testdata/extensions/basic/index.js index 79176b6b..45fb095a 100644 --- a/pkg/download/testdata/extensions/basic/index.js +++ b/pkg/download/testdata/extensions/basic/index.js @@ -6,6 +6,9 @@ gopeed.events.onResolve(async function (ctx) { size: 1024, req: { url: ctx.req.url + "/" + i, + labels:{ + "from": gopeed.info.name, + } } }), ), diff --git a/pkg/download/testdata/extensions/basic/manifest.json b/pkg/download/testdata/extensions/basic/manifest.json index ab8ad2bc..9560ced7 100644 --- a/pkg/download/testdata/extensions/basic/manifest.json +++ b/pkg/download/testdata/extensions/basic/manifest.json @@ -5,9 +5,11 @@ "scripts": [ { "event": "onResolve", - "matches": [ - "*://github.com/*" - ], + "match": { + "urls": [ + "*://github.com/*" + ] + }, "entry": "index.js" } ], diff --git a/pkg/download/testdata/extensions/function_error/manifest.json b/pkg/download/testdata/extensions/function_error/manifest.json index 65337859..bf53c040 100644 --- a/pkg/download/testdata/extensions/function_error/manifest.json +++ b/pkg/download/testdata/extensions/function_error/manifest.json @@ -5,9 +5,11 @@ "scripts": [ { "event": "onResolve", - "matches": [ - "*://github.com/*" - ], + "match": { + "urls": [ + "*://github.com/*" + ] + }, "entry": "index.js" } ], diff --git a/pkg/download/testdata/extensions/on_start/index.js b/pkg/download/testdata/extensions/on_start/index.js new file mode 100644 index 00000000..cc6a6d5f --- /dev/null +++ b/pkg/download/testdata/extensions/on_start/index.js @@ -0,0 +1,5 @@ +gopeed.events.onStart(async function (ctx) { + ctx.task.meta.req.url = "https://github.com"; + ctx.task.meta.req.labels['modified'] = 'true'; +}); + diff --git a/pkg/download/testdata/extensions/on_start/manifest.json b/pkg/download/testdata/extensions/on_start/manifest.json new file mode 100644 index 00000000..ad38340d --- /dev/null +++ b/pkg/download/testdata/extensions/on_start/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "on-start", + "title": "gopeed extension on start event test", + "version": "0.0.1", + "scripts": [ + { + "event": "onStart", + "match": { + "urls": [ + "*://github.com/*" + ], + "labels": [ + "test" + ] + }, + "entry": "index.js" + } + ] +} \ No newline at end of file diff --git a/pkg/download/testdata/extensions/script_error/manifest.json b/pkg/download/testdata/extensions/script_error/manifest.json index 978745ba..0aaca82d 100644 --- a/pkg/download/testdata/extensions/script_error/manifest.json +++ b/pkg/download/testdata/extensions/script_error/manifest.json @@ -5,9 +5,11 @@ "scripts": [ { "event": "onResolve", - "matches": [ - "*://github.com/*" - ], + "match": { + "urls": [ + "*://github.com/*" + ] + }, "entry": "index.js" } ], diff --git a/pkg/download/testdata/extensions/settings_all/index.js b/pkg/download/testdata/extensions/settings_all/index.js index 4454268b..7144506a 100644 --- a/pkg/download/testdata/extensions/settings_all/index.js +++ b/pkg/download/testdata/extensions/settings_all/index.js @@ -1,31 +1,31 @@ gopeed.events.onResolve(async function (ctx) { - if (ctx.settings.string != null) { + if (gopeed.settings.string != null) { throw new Error("string is not null"); } - if (ctx.settings.number != null) { + if (gopeed.settings.number != null) { throw new Error("number is not null"); } - if (ctx.settings.boolean != null) { + if (gopeed.settings.boolean != null) { throw new Error("boolean is not null"); } - if (ctx.settings.stringDefault !== "default") { + if (gopeed.settings.stringDefault !== "default") { throw new Error("string default value is incorrect"); } - if (ctx.settings.numberDefault !== 1) { + if (gopeed.settings.numberDefault !== 1) { throw new Error("number default value is incorrect"); } - if (ctx.settings.booleanDefault !== true) { + if (gopeed.settings.booleanDefault !== true) { throw new Error("boolean default value is incorrect"); } - if (ctx.settings.stringValued !== "valued") { + if (gopeed.settings.stringValued !== "valued") { throw new Error("string value is incorrect"); } - if (ctx.settings.numberValued !== 1.1) { + if (gopeed.settings.numberValued !== 1.1) { throw new Error("number value is incorrect"); } - if (ctx.settings.booleanValued !== true) { + if (gopeed.settings.booleanValued !== true) { throw new Error("boolean value is incorrect"); } diff --git a/pkg/download/testdata/extensions/settings_all/manifest.json b/pkg/download/testdata/extensions/settings_all/manifest.json index ae6b9540..37b45666 100644 --- a/pkg/download/testdata/extensions/settings_all/manifest.json +++ b/pkg/download/testdata/extensions/settings_all/manifest.json @@ -5,9 +5,11 @@ "scripts": [ { "event": "onResolve", - "matches": [ - "*://*/*" - ], + "match": { + "urls": [ + "*://*/*" + ] + }, "entry": "index.js" } ], diff --git a/pkg/download/testdata/extensions/settings_empty/index.js b/pkg/download/testdata/extensions/settings_empty/index.js index 4d47d019..4f837423 100644 --- a/pkg/download/testdata/extensions/settings_empty/index.js +++ b/pkg/download/testdata/extensions/settings_empty/index.js @@ -1,5 +1,5 @@ gopeed.events.onResolve(async function (ctx) { - if (Object.keys(ctx.settings).length > 0){ + if (Object.keys(gopeed.settings).length > 0){ throw new Error("settings is not empty"); } diff --git a/pkg/download/testdata/extensions/settings_empty/manifest.json b/pkg/download/testdata/extensions/settings_empty/manifest.json index 0ae921ae..146a918e 100644 --- a/pkg/download/testdata/extensions/settings_empty/manifest.json +++ b/pkg/download/testdata/extensions/settings_empty/manifest.json @@ -5,9 +5,11 @@ "scripts": [ { "event": "onResolve", - "matches": [ - "*://*/*" - ], + "match": { + "urls": [ + "*://*/*" + ] + }, "entry": "index.js" } ] diff --git a/pkg/download/testdata/extensions/storage/index.js b/pkg/download/testdata/extensions/storage/index.js index d101e4aa..2d7b3d1f 100644 --- a/pkg/download/testdata/extensions/storage/index.js +++ b/pkg/download/testdata/extensions/storage/index.js @@ -2,36 +2,36 @@ gopeed.events.onResolve(async function (ctx) { const key = "key" const value1 = "value1", value2 = JSON.stringify({a: 1, b: "2"}) - if (ctx.storage.get(key) !== null) { + if (gopeed.storage.get(key) !== null) { throw new Error("storage get null error") } - ctx.storage.remove(key) - if(ctx.storage.keys().length !== 0) { + gopeed.storage.remove(key) + if(gopeed.storage.keys().length !== 0) { throw new Error("storage keys null error") } - ctx.storage.set(key, value1) - if (ctx.storage.get(key) !== value1) { + gopeed.storage.set(key, value1) + if (gopeed.storage.get(key) !== value1) { throw new Error("storage put1 error") } - ctx.storage.set(key, value2) - if (ctx.storage.get(key) !== value2) { + gopeed.storage.set(key, value2) + if (gopeed.storage.get(key) !== value2) { throw new Error("storage put2 error") } - if(ctx.storage.keys().length !== 1) { + if(gopeed.storage.keys().length !== 1) { throw new Error("storage keys error") } - ctx.storage.remove(key) - if (ctx.storage.get(key) !== null) { + gopeed.storage.remove(key) + if (gopeed.storage.get(key) !== null) { throw new Error("storage delete error") } - ctx.storage.set(key, value1) - ctx.storage.clear() - if (ctx.storage.get(key) !== null) { + gopeed.storage.set(key, value1) + gopeed.storage.clear() + if (gopeed.storage.get(key) !== null) { throw new Error("storage clear error") } diff --git a/pkg/download/testdata/extensions/storage/manifest.json b/pkg/download/testdata/extensions/storage/manifest.json index 5b8f41c9..c7953bb9 100644 --- a/pkg/download/testdata/extensions/storage/manifest.json +++ b/pkg/download/testdata/extensions/storage/manifest.json @@ -5,9 +5,11 @@ "scripts": [ { "event": "onResolve", - "matches": [ - "*://github.com/*" - ], + "match": { + "urls": [ + "*://*/*" + ] + }, "entry": "index.js" } ], diff --git a/pkg/download/testdata/extensions/update/manifest.json b/pkg/download/testdata/extensions/update/manifest.json index 30522af3..9db763f8 100644 --- a/pkg/download/testdata/extensions/update/manifest.json +++ b/pkg/download/testdata/extensions/update/manifest.json @@ -12,9 +12,11 @@ "scripts": [ { "event": "onResolve", - "matches": [ - "*://github.com/*/releases/*" - ], + "match": { + "urls": [ + "*://*/*" + ] + }, "entry": "index.js" } ] diff --git a/ui/flutter/lib/api/model/extension.dart b/ui/flutter/lib/api/model/extension.dart index da13b403..3bd3196e 100644 --- a/ui/flutter/lib/api/model/extension.dart +++ b/ui/flutter/lib/api/model/extension.dart @@ -16,6 +16,7 @@ class Extension { List? settings; bool disabled; bool devMode; + String devPath; Extension({ required this.identity, @@ -29,6 +30,7 @@ class Extension { required this.repository, required this.disabled, required this.devMode, + required this.devPath, }); factory Extension.fromJson(Map json) => diff --git a/ui/flutter/lib/api/model/extension.g.dart b/ui/flutter/lib/api/model/extension.g.dart index a4c781fd..6afa4961 100644 --- a/ui/flutter/lib/api/model/extension.g.dart +++ b/ui/flutter/lib/api/model/extension.g.dart @@ -20,6 +20,7 @@ Extension _$ExtensionFromJson(Map json) => Extension( : Repository.fromJson(json['repository'] as Map), disabled: json['disabled'] as bool, devMode: json['devMode'] as bool, + devPath: json['devPath'] as String, )..settings = (json['settings'] as List?) ?.map((e) => Setting.fromJson(e as Map)) .toList(); @@ -46,6 +47,7 @@ Map _$ExtensionToJson(Extension instance) { writeNotNull('settings', instance.settings?.map((e) => e.toJson()).toList()); val['disabled'] = instance.disabled; val['devMode'] = instance.devMode; + val['devPath'] = instance.devPath; return val; } diff --git a/ui/flutter/lib/api/model/request.dart b/ui/flutter/lib/api/model/request.dart index 27eeef71..5e39b1f5 100644 --- a/ui/flutter/lib/api/model/request.dart +++ b/ui/flutter/lib/api/model/request.dart @@ -6,10 +6,12 @@ part 'request.g.dart'; class Request { String url; Object? extra; + Map? labels = {}; Request({ required this.url, this.extra, + this.labels, }); factory Request.fromJson(Map json) => diff --git a/ui/flutter/lib/api/model/request.g.dart b/ui/flutter/lib/api/model/request.g.dart index d87c0de9..3ddd44ad 100644 --- a/ui/flutter/lib/api/model/request.g.dart +++ b/ui/flutter/lib/api/model/request.g.dart @@ -9,6 +9,9 @@ part of 'request.dart'; Request _$RequestFromJson(Map json) => Request( url: json['url'] as String, extra: json['extra'], + labels: (json['labels'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), ); Map _$RequestToJson(Request instance) { @@ -23,6 +26,7 @@ Map _$RequestToJson(Request instance) { } writeNotNull('extra', instance.extra); + writeNotNull('labels', instance.labels); return val; } diff --git a/ui/flutter/lib/app/modules/extension/views/extension_view.dart b/ui/flutter/lib/app/modules/extension/views/extension_view.dart index 084f1f9b..f5d07e7a 100644 --- a/ui/flutter/lib/app/modules/extension/views/extension_view.dart +++ b/ui/flutter/lib/app/modules/extension/views/extension_view.dart @@ -110,14 +110,22 @@ class ExtensionView extends GetView { height: 48, ) : Util.isWeb() - ? Image.network(join( - '/fs/extensions/${extension.identity}/${extension.icon}')) + ? Image.network( + join( + '/fs/extensions/${extension.identity}/${extension.icon}'), + width: 48, + height: 48, + ) : Image.file( - File(path.join( - Util.getStorageDir(), - "extensions", - extension.identity, - extension.icon)), + extension.devMode + ? File(path.join( + extension.devPath, + extension.icon)) + : File(path.join( + Util.getStorageDir(), + "extensions", + extension.identity, + extension.icon)), width: 48, height: 48, ),