@synopackageland/templates 0.9.3 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,7 +21,7 @@ An overlay may replace `internal/app/*_none.go` (the CLI deletes the matching
21
21
  The placeholders `__PACKAGE__` (also the Go module path), `__DISPLAY_NAME__` and
22
22
  `__BACKGROUND_ENABLED__` are substituted by `init`.
23
23
 
24
- `backend/go.mod` requires `github.com/synopackageland/syno-pkg-land-sdk v0.9.4`; that line is
24
+ `backend/go.mod` requires `github.com/synopackageland/syno-pkg-land-sdk v0.9.6`; that line is
25
25
  the single source of the SDK version and `frontend/package.json` pins
26
26
  `@synopackageland/sdk` to the same version.
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/templates",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "Project templates for synopkgland init: native-go (plain or Vue, with jobs, delegate and root-ops overlays), binary and compose.",
5
5
  "license": "MIT",
6
6
  "repository": {"type": "git", "url": "git+https://github.com/synopackageland/syno-pkg-land-templates.git"},
@@ -1,6 +1,6 @@
1
1
  Native Go package build dependencies
2
2
 
3
- github.com/synopackageland/syno-pkg-land-sdk v0.9.4 (MIT)
3
+ github.com/synopackageland/syno-pkg-land-sdk v0.9.6 (MIT)
4
4
 
5
5
  Copyright (c) 2026 synopkgland
6
6
 
@@ -36,7 +36,8 @@ var (
36
36
  identity = ""
37
37
  // dsm-package.yaml entry: "admin-only" or "app-privilege"; empty keeps the route policy alone.
38
38
  entry = ""
39
- // "on" when dsm-package.yaml declares portal: serves portal.status.
39
+ // "on" when dsm-package.yaml declares portal: serves portal.status;
40
+ // "executor" below DSM 7.2, where the executor registers the entry.
40
41
  portal = ""
41
42
  )
42
43
 
@@ -53,10 +54,11 @@ func main() {
53
54
  DelegateDestinationsJSON: delegateDestinationsJSON,
54
55
  DelegateLifetime: delegateLifetime,
55
56
  ExecutorDigest: executorDigest, PolicyDigest: policyDigest,
56
- PackageID: packageID,
57
- Version: version,
58
- DataDir: "/var/packages/" + packageID + "/var",
59
- Portal: portal == "on",
57
+ PackageID: packageID,
58
+ Version: version,
59
+ DataDir: "/var/packages/" + packageID + "/var",
60
+ Portal: portal != "",
61
+ PortalExecutor: portal == "executor",
60
62
  }
61
63
  if _, err := app.ParseDelegateConfig(cfg); err != nil {
62
64
  log.Fatal(err)
@@ -4,6 +4,6 @@ go 1.25.0
4
4
 
5
5
  toolchain go1.26.9
6
6
 
7
- require github.com/synopackageland/syno-pkg-land-sdk v0.9.4
7
+ require github.com/synopackageland/syno-pkg-land-sdk v0.9.6
8
8
 
9
9
  require golang.org/x/sys v0.44.0 // indirect
@@ -1,4 +1,4 @@
1
- github.com/synopackageland/syno-pkg-land-sdk v0.9.4 h1:ZfM5aZ/zjbIqpRda4fdeMYnibLh6UjGtxJudFlk/Dno=
2
- github.com/synopackageland/syno-pkg-land-sdk v0.9.4/go.mod h1:JNoqISvR4biA9IuLi0CFe4c272tsKzCN9Nyj71nrXsQ=
1
+ github.com/synopackageland/syno-pkg-land-sdk v0.9.6 h1:jc5AmFKWKkpwxwG7RKiKgzI5WbA/DEm0FRfDbQwWXnQ=
2
+ github.com/synopackageland/syno-pkg-land-sdk v0.9.6/go.mod h1:hlsvaVuH2wsAoR8jzI1Rg/NeejQSFpAaN6zpIWsVbjI=
3
3
  golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
4
4
  golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -3,28 +3,73 @@ package app
3
3
  import (
4
4
  "context"
5
5
  "encoding/json"
6
+ "errors"
7
+ "net/http"
6
8
 
7
9
  "github.com/synopackageland/syno-pkg-land-sdk/api"
10
+ "github.com/synopackageland/syno-pkg-land-sdk/delegate"
8
11
  "github.com/synopackageland/syno-pkg-land-sdk/portal"
9
12
  )
10
13
 
11
14
  // portalRoutes serves portal.status when dsm-package.yaml declares `portal`.
12
15
  // An administrator sets the alias with DSM's own AppPortal API from the UI;
13
- // the package only reads DSM's saved setting.
16
+ // the package only reads DSM's saved setting. Below DSM 7.2 the executor
17
+ // registers the App's entry (PortalExecutor): portal.status then reports it,
18
+ // and portal.register and portal.unregister change it.
14
19
  func (cfg Config) portalRoutes() []api.Route {
15
20
  if !cfg.Portal {
16
21
  return nil
17
22
  }
18
- return []api.Route{api.Handle("portal.status", cfg.portalStatus)}
23
+ routes := []api.Route{api.Handle("portal.status", cfg.portalStatus)}
24
+ if cfg.PortalExecutor {
25
+ routes = append(routes,
26
+ api.HandleRequest("portal.register", cfg.portalChange(portal.Register)),
27
+ api.HandleRequest("portal.unregister", cfg.portalChange(portal.Unregister)))
28
+ }
29
+ return routes
19
30
  }
20
31
 
21
- func (cfg Config) portalStatus(_ context.Context, _ api.Caller, params json.RawMessage) (any, error) {
32
+ // portalExecutor is a variable so tests can replace the executor.
33
+ var portalExecutor = func(cfg Config) portal.Executor { return delegate.Local(cfg.PackageID) }
34
+
35
+ func (cfg Config) portalStatus(ctx context.Context, _ api.Caller, params json.RawMessage) (any, error) {
22
36
  if err := decodeParams(params, &struct{}{}); err != nil {
23
37
  return nil, err
24
38
  }
39
+ return cfg.readPortal(ctx), nil
40
+ }
41
+
42
+ func (cfg Config) readPortal(ctx context.Context) portal.Status {
25
43
  path := cfg.PortalConfig
26
44
  if path == "" {
27
45
  path = portal.ConfigPath(cfg.PackageID)
28
46
  }
29
- return portal.Read(path, "SYNO.SDS."+cfg.PackageID+".Instance"), nil
47
+ s := portal.Read(path, "SYNO.SDS."+cfg.PackageID+".Instance")
48
+ if !cfg.PortalExecutor {
49
+ return s
50
+ }
51
+ // An unreadable entry is not a known one: WithEntry reports unavailable.
52
+ entry, _ := portal.ExecutorEntry(ctx, portalExecutor(cfg))
53
+ return portal.WithEntry(s, entry)
54
+ }
55
+
56
+ // portalChange forwards the administrator's DSM session; the executor checks
57
+ // it again and keeps its refusal codes, such as PORTAL_FAILED.
58
+ func (cfg Config) portalChange(change func(context.Context, portal.Executor, map[string]string) (string, error)) api.RequestHandler {
59
+ return func(ctx context.Context, caller api.Caller, req *http.Request, params json.RawMessage) (any, error) {
60
+ if !caller.Admin() {
61
+ return nil, errors.New("admin_required")
62
+ }
63
+ if err := decodeParams(params, &struct{}{}); err != nil {
64
+ return nil, err
65
+ }
66
+ auth, err := api.DSMSession(req)
67
+ if err != nil {
68
+ return nil, err
69
+ }
70
+ if _, err := change(ctx, portalExecutor(cfg), auth); err != nil {
71
+ return nil, err
72
+ }
73
+ return cfg.readPortal(ctx), nil
74
+ }
30
75
  }
@@ -1,9 +1,17 @@
1
1
  package app
2
2
 
3
3
  import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
4
7
  "os"
5
8
  "path/filepath"
6
9
  "testing"
10
+
11
+ "github.com/synopackageland/syno-pkg-land-sdk/api"
12
+ "github.com/synopackageland/syno-pkg-land-sdk/portal"
13
+
14
+ appapi "__PACKAGE__/internal/api"
7
15
  )
8
16
 
9
17
  func portalConfig(t *testing.T, saved string) Config {
@@ -53,3 +61,123 @@ func TestPortalStatusExistsOnlyForAPortalBuild(t *testing.T) {
53
61
  t.Fatalf("%q", code)
54
62
  }
55
63
  }
64
+
65
+ // fakeEntry is the executor as the portal routes see it.
66
+ type fakeEntry struct {
67
+ entry string
68
+ err error
69
+ calls []string
70
+ auth map[string]string
71
+ }
72
+
73
+ func (f *fakeEntry) Call(_ context.Context, op string, auth map[string]string, _, result any) error {
74
+ f.calls = append(f.calls, op)
75
+ if f.err != nil {
76
+ return f.err
77
+ }
78
+ switch op {
79
+ case "portal.register":
80
+ f.entry, f.auth = portal.EntryRegistered, auth
81
+ case "portal.unregister":
82
+ f.entry, f.auth = portal.EntryUnregistered, auth
83
+ }
84
+ key := "entry"
85
+ if op == "status" {
86
+ key = "portal"
87
+ }
88
+ b, _ := json.Marshal(map[string]string{key: f.entry})
89
+ return json.Unmarshal(b, result)
90
+ }
91
+
92
+ func executorPortalConfig(t *testing.T, saved string, executor *fakeEntry) Config {
93
+ cfg := portalConfig(t, saved)
94
+ cfg.PortalExecutor = true
95
+ previous := portalExecutor
96
+ portalExecutor = func(Config) portal.Executor { return executor }
97
+ t.Cleanup(func() { portalExecutor = previous })
98
+ return cfg
99
+ }
100
+
101
+ func sessionCall(t *testing.T, cfg Config, method string) (string, map[string]any) {
102
+ t.Helper()
103
+ permissions, err := appapi.Permissions()
104
+ if err != nil {
105
+ t.Fatal(err)
106
+ }
107
+ req := apiRequest(method, `{}`)
108
+ req.Header.Set("Cookie", "id=session")
109
+ req.Header.Set("X-SYNO-TOKEN", "token")
110
+ return serve(t, cfg, alice, req, permissions)
111
+ }
112
+
113
+ func TestExecutorPortalStatusReportsTheEntry(t *testing.T) {
114
+ saved := `{"alias":"hello-go","id":"SYNO.SDS.HelloGo.Instance"}`
115
+ for _, c := range []struct {
116
+ entry string
117
+ err error
118
+ state any
119
+ alias any
120
+ }{
121
+ {portal.EntryRegistered, nil, "active", "hello-go"},
122
+ {portal.EntryCleanupRequired, nil, "active", "hello-go"},
123
+ {portal.EntryUnregistered, nil, "inactive", nil},
124
+ {"", errors.New("executor down"), "unavailable", nil},
125
+ } {
126
+ cfg := executorPortalConfig(t, saved, &fakeEntry{entry: c.entry, err: c.err})
127
+ code, out := call(t, cfg, bob, "portal.status", `{}`)
128
+ data := out["data"].(map[string]any)
129
+ if code != "" || data["state"] != c.state || data["alias"] != c.alias {
130
+ t.Fatalf("%s: %q %v", c.entry, code, data)
131
+ }
132
+ if c.err == nil && data["entry"] != c.entry {
133
+ t.Fatalf("%s: entry %v", c.entry, data)
134
+ }
135
+ }
136
+ }
137
+
138
+ func TestExecutorPortalRegisterAndUnregisterReturnTheFreshStatus(t *testing.T) {
139
+ executor := &fakeEntry{entry: portal.EntryUnregistered}
140
+ cfg := executorPortalConfig(t, `{"alias":"hello-go","id":"SYNO.SDS.HelloGo.Instance"}`, executor)
141
+ code, out := sessionCall(t, cfg, "portal.register")
142
+ if data, _ := out["data"].(map[string]any); code != "" || data["entry"] != portal.EntryRegistered || data["state"] != "active" {
143
+ t.Fatalf("register: %q %v", code, out)
144
+ }
145
+ if executor.auth["dsm_cookie"] != "session" {
146
+ t.Fatalf("register did not forward the DSM session: %v", executor.auth)
147
+ }
148
+ code, out = sessionCall(t, cfg, "portal.unregister")
149
+ if data, _ := out["data"].(map[string]any); code != "" || data["entry"] != portal.EntryUnregistered || data["state"] != "inactive" {
150
+ t.Fatalf("unregister: %q %v", code, out)
151
+ }
152
+ }
153
+
154
+ func TestExecutorPortalChangesNeedAnAdministratorsDSMSession(t *testing.T) {
155
+ executor := &fakeEntry{entry: portal.EntryUnregistered}
156
+ cfg := executorPortalConfig(t, "", executor)
157
+ for _, method := range []string{"portal.register", "portal.unregister"} {
158
+ if code, _ := call(t, cfg, bob, method, `{}`); code == "" {
159
+ t.Fatalf("%s: an ordinary user changed the entry", method)
160
+ }
161
+ if code, _ := call(t, cfg, alice, method, `{}`); code != "session_required" {
162
+ t.Fatalf("%s without a DSM session: %q", method, code)
163
+ }
164
+ }
165
+ if len(executor.calls) != 0 {
166
+ t.Fatalf("the executor was asked: %v", executor.calls)
167
+ }
168
+ }
169
+
170
+ func TestExecutorPortalRefusalKeepsTheExecutorsCode(t *testing.T) {
171
+ cfg := executorPortalConfig(t, "", &fakeEntry{err: &api.Error{Code: "PORTAL_FAILED", Message: "x"}})
172
+ if code, _ := sessionCall(t, cfg, "portal.register"); code != "PORTAL_FAILED" {
173
+ t.Fatalf("%q", code)
174
+ }
175
+ }
176
+
177
+ func TestPortalChangesExistOnlyInAnExecutorPortalBuild(t *testing.T) {
178
+ for _, method := range []string{"portal.register", "portal.unregister"} {
179
+ if code, _ := call(t, portalConfig(t, ""), alice, method, `{}`); code != "unknown_method" {
180
+ t.Fatalf("%s: %q", method, code)
181
+ }
182
+ }
183
+ }
@@ -37,10 +37,12 @@ type Config struct {
37
37
  DataDir string
38
38
  // VolumeRoot is where /volumeN lives; empty means "/". Tests set it.
39
39
  VolumeRoot string
40
- // Portal serves portal.status; PortalConfig overrides DSM's saved
41
- // AppPortal.json path in tests.
42
- Portal bool
43
- PortalConfig string
40
+ // Portal serves portal.status; PortalExecutor adds the executor's entry
41
+ // below DSM 7.2. PortalConfig overrides DSM's saved AppPortal.json path in
42
+ // tests.
43
+ Portal bool
44
+ PortalExecutor bool
45
+ PortalConfig string
44
46
  }
45
47
 
46
48
  func Routes(cfg Config) []api.Route {
@@ -22,6 +22,8 @@ func init() {
22
22
  sampleParams["delegate.jobs.submit"] = `{"key":"download","url":"http://localhost/file","dest":"/home","name":"file"}`
23
23
  sampleParams["delegate.jobs.list"] = `{}`
24
24
  sampleParams["delegate.jobs.cancel"] = `{"id":"missing"}`
25
+ // delegate.status judges the caller's Application Privileges, so it needs the DSM session too.
26
+ sampleOutcome["delegate.status"] = "session_required"
25
27
  sampleOutcome["delegate.create"] = "session_required"
26
28
  sampleOutcome["delegate.list"] = "session_required"
27
29
  sampleOutcome["delegate.revoke"] = "session_required"
@@ -37,6 +37,10 @@ Use the installed `@synopackageland/ui`; package.json pins its version.
37
37
  `AppLayout wide-layout="window"` keeps the DSM shell in window mode even when
38
38
  maximized. Only whole-page web/Portal apps should opt into document scope or
39
39
  `wide-layout="web"`. The DSM host sets the 700px minimum window width.
40
+ In a DSM window (`ctx.host.platform().host === 'dsm'`) `AppRoot` takes
41
+ `dialog-bounds="viewport"`, so Dialogs can be dragged out of the window, and
42
+ its `activate` event calls `ctx.host.activateWindow()` to bring the window to
43
+ the front. Elsewhere Dialogs stay inside the App.
40
44
  Import `themes/dsm7-embedded.css` and assign each mount a unique
41
45
  `app.config.idPrefix` before mounting.
42
46
 
@@ -36,6 +36,8 @@ editor; the root overlay adds its existing fixed-operation authorization gate.
36
36
  Use the installed `@synopackageland/ui` controls and `themes/dsm7-embedded.css`.
37
37
  The theme owns colors, typography, spacing and dialogs. Keep per-app modal scope,
38
38
  a unique Vue ID prefix and vertical scrolling at the DSM minimum window size.
39
+ In a DSM window, Dialogs may leave the window (`dialog-bounds="viewport"`) and a
40
+ press on one brings the window to the front; below 600px they are bottom sheets.
39
41
 
40
42
  Refresh and save expose busy state and localized errors. Reopen refreshes status.
41
43
  Each mount owns its state; unmount unsubscribes from host events and ignores late
@@ -7,8 +7,8 @@
7
7
  "name": "native-dsm-vue-app",
8
8
  "dependencies": {
9
9
  "@lucide/vue": "1.48.0",
10
- "@synopackageland/sdk": "0.9.4",
11
- "@synopackageland/ui": "0.9.1",
10
+ "@synopackageland/sdk": "0.9.6",
11
+ "@synopackageland/ui": "0.9.2",
12
12
  "vue": "3.5.43"
13
13
  },
14
14
  "devDependencies": {
@@ -1148,9 +1148,9 @@
1148
1148
  }
1149
1149
  },
1150
1150
  "node_modules/@synopackageland/sdk": {
1151
- "version": "0.9.4",
1152
- "resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.9.4.tgz",
1153
- "integrity": "sha512-GvPDd8hoU5mzkXD4s/RShY1dBiu/zRoPtd0zFb/yjciZxlzdgopn4VnOjhq7xScbrmK9RuVZRbXLGT16NdobUg==",
1151
+ "version": "0.9.6",
1152
+ "resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.9.6.tgz",
1153
+ "integrity": "sha512-u0xoXjlNk1buGUDO/5KxQuS88zmwG/fIUlToLEIoyoEQfb3K0p/Jt8SVKg3+XuoWEiMZ3mzRkcTQ+AEnr3LKMw==",
1154
1154
  "license": "MIT",
1155
1155
  "peerDependencies": {
1156
1156
  "@lucide/vue": "^1.48.0",
@@ -1167,9 +1167,9 @@
1167
1167
  }
1168
1168
  },
1169
1169
  "node_modules/@synopackageland/ui": {
1170
- "version": "0.9.1",
1171
- "resolved": "https://registry.npmjs.org/@synopackageland/ui/-/ui-0.9.1.tgz",
1172
- "integrity": "sha512-L2k0eeN9+TLPMh5+kMbq6pV8n0qqUq6Ynme/KSp3mCqDb1MXBdr8MbAGtxdbNYMj9QepbA9mccgygwI9fFoG5A==",
1170
+ "version": "0.9.2",
1171
+ "resolved": "https://registry.npmjs.org/@synopackageland/ui/-/ui-0.9.2.tgz",
1172
+ "integrity": "sha512-GXYs6oVed0tIb4J5D5aDYoLJMs7haq0DBWKrPP1cLU6QKUQ6EKaIwqWHv0sVdu530FCARjcB1PrWwWVS9WOsvA==",
1173
1173
  "license": "MIT",
1174
1174
  "dependencies": {
1175
1175
  "@internationalized/date": "3.12.4",
@@ -8,8 +8,8 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@lucide/vue": "1.48.0",
11
- "@synopackageland/sdk": "0.9.4",
12
- "@synopackageland/ui": "0.9.1",
11
+ "@synopackageland/sdk": "0.9.6",
12
+ "@synopackageland/ui": "0.9.2",
13
13
  "vue": "3.5.43"
14
14
  },
15
15
  "devDependencies": {
@@ -17,6 +17,10 @@ let disposed = false
17
17
  let unsubscribe: (() => void) | undefined
18
18
  // The CLI substitutes this only for a declared package-account background worker.
19
19
  const backgroundEnabled = (('__BACKGROUND_ENABLED__') as string) === 'true'
20
+ // In a DSM window, dialogs may leave the window as DSM's own do; a press on one
21
+ // does not reach the window, so it brings the window to the front itself.
22
+ const inDsmWindow = props.ctx.host?.platform().host === 'dsm'
23
+ function toFront() {props.ctx.host.activateWindow()}
20
24
  const page = ref<ShellPage>('status')
21
25
  const pages = computed(() => shellPages({background:backgroundEnabled}))
22
26
  const subtitle = computed(() => app.value ? `${app.value.package} · ${app.value.user} · ${app.value.version}` : undefined)
@@ -64,7 +68,7 @@ onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose();text.value=''})
64
68
  </script>
65
69
 
66
70
  <template>
67
- <AppRoot modal-scope="app" style="height: 100%">
71
+ <AppRoot modal-scope="app" :dialog-bounds="inDsmWindow ? 'viewport' : 'app'" style="height: 100%" @activate="toFront">
68
72
  <ConfigProvider :locale="uiLocale(ctx.locale)">
69
73
  <LoginPage v-if="login.ready.value && login.enabled.value && !login.subject.value" :session="login" :locale="ctx.locale" />
70
74
  <AppShell v-else v-model="page" :locale="ctx.locale" :pages="pages" :description="subtitle">
@@ -92,3 +92,40 @@ test('a status request that raced the session check is retried once the session
92
92
  expect(root.textContent).toContain('Raced · admin · 1')
93
93
  }finally{app.unmount();root.remove()}
94
94
  })
95
+ function hosted(host?:'dsm'|'standalone-web'){
96
+ const call=vi.fn(async(method:string)=>{
97
+ if(method==='app.status')return {package:'Hosted',version:'1',user:'admin',admin:false}
98
+ throw {code:'unknown_method'}
99
+ })
100
+ const activateWindow=vi.fn(()=>({status:'requested'}))
101
+ const ctx={locale:'en',call,reopen(){return ()=>{}},...(host?{host:{platform:()=>({host}),activateWindow}}:{})} as unknown as Context
102
+ const root=document.createElement('div');document.body.append(root)
103
+ const app=mount(root,ctx)
104
+ return {activateWindow,close(){app.unmount();root.remove()}}
105
+ }
106
+ test('in a DSM window dialogs may leave the window, and pressing one brings the window to the front',async()=>{
107
+ const f=hosted('dsm')
108
+ try{
109
+ await settle()
110
+ const floating=document.querySelector<HTMLElement>('[data-ui-floating]')
111
+ expect(floating).not.toBeNull()
112
+ floating!.dispatchEvent(new Event('pointerdown'))
113
+ expect(f.activateWindow).toHaveBeenCalledOnce()
114
+ }finally{f.close()}
115
+ expect(document.querySelector('[data-ui-floating]')).toBeNull()
116
+ })
117
+ test('on a narrow viewport the floating dialogs become sheets that stay on screen',async()=>{
118
+ const width=window.innerWidth
119
+ Object.defineProperty(window,'innerWidth',{configurable:true,value:390})
120
+ const f=hosted('dsm')
121
+ try{
122
+ await settle()
123
+ expect(document.querySelector('[data-ui-floating]')?.getAttribute('data-ui-presentation')).toBe('mobile')
124
+ }finally{f.close();Object.defineProperty(window,'innerWidth',{configurable:true,value:width})}
125
+ })
126
+ test('outside a DSM window dialogs stay inside the app',async()=>{
127
+ for(const host of [undefined,'standalone-web'] as const){
128
+ const f=hosted(host)
129
+ try{await settle();expect(document.querySelector('[data-ui-floating]')).toBeNull()}finally{f.close()}
130
+ }
131
+ })