@synopackageland/templates 0.15.2 → 0.17.0

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
@@ -38,12 +38,12 @@ calls each one from the generated script of the same name (`synopkgland skill sh
38
38
  The placeholders `__PACKAGE__` (also the Go module path), `__DISPLAY_NAME__` and
39
39
  `__BACKGROUND_ENABLED__` are substituted by `init`.
40
40
 
41
- `backend/go.mod` requires `github.com/synopackageland/syno-pkg-land-sdk v0.15.2`; that line is
41
+ `backend/go.mod` requires `github.com/synopackageland/syno-pkg-land-sdk v0.17.0`; that line is
42
42
  the single source of the SDK version and `frontend/package.json` pins
43
43
  `@synopackageland/sdk` to the same version. Python and Node.js name it in
44
44
  `backend.sdk`, Rust in the `syno-pkg-land-app` tag of `backend/Cargo.toml`
45
- (and `Cargo.lock`); all of them pin 0.15.2. The Vue frontend pins
46
- `@synopackageland/ui` 0.12.0. SDK 0.15.2 accepts UI `>=0.10.1 <0.13.0`,
45
+ (and `Cargo.lock`); all of them pin 0.17.0. The Vue frontend pins
46
+ `@synopackageland/ui` 0.12.0. SDK 0.17.0 accepts UI `>=0.10.1 <0.13.0`,
47
47
  so no UI peer override is needed. Tests use jsdom 30 for UI floating-layer styles.
48
48
 
49
49
  ## Notice writer
@@ -149,3 +149,9 @@ The CLI preserves upstream bytes and the SDK planner generates an override and o
149
149
  ## AI commands
150
150
 
151
151
  All four native templates explicitly declare status and greeting commands in `ctl.commands`. Build packages a private `synopkgland-api.json`; only the authenticated SDK description endpoint returns it. Internal routes are not automatically commands. Read `synopkgland skill show ctl` before adding command schemas or workflow bindings.
152
+
153
+ The Vue scaffold batches independent startup status/greeting reads when the
154
+ window already identifies an administrator and supplies `ctx.batch`. Rebuild
155
+ with the current CLI and SDK to get that client. Older generated contexts keep
156
+ the single-call path. Each batch result is handled separately; the shell stays
157
+ visible while reads run. The backend permissions remain authoritative.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/templates",
3
- "version": "0.15.2",
3
+ "version": "0.17.0",
4
4
  "description": "Project templates for synopkgland init: native-go, native-python, native-node and native-rust (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"},
@@ -62,26 +62,30 @@ call `greeting.set`.
62
62
 
63
63
  ## DSM Application Portal (`portal`)
64
64
 
65
- Only an App with the root executor (`rootOps` or `delegate`) may declare a
66
- portal: its uninstall then reloads nginx so the alias stops answering, which a
67
- no-privilege App cannot do without root. `synopkgland add portal` (with
68
- `root-ops` or `delegate` in the same call or already declared; or `portal: {}` /
69
- `portal: {alias: my-app}` in dsm-package.yaml) registers
70
- `https://<nas>/<alias>/`, which opens the app after DSM sign-in. Compose Apps
71
- cannot declare a portal. Read `synopkgland skill show portal` before changing it.
72
- The install wizard only proposes the alias; an administrator activates it from
73
- the app's portal panel (`ctx.portal`, DSM's own `SYNO.Core.AppPortal` in the
74
- admin's session) or Control Panel > Login Portal > Applications. Ordinary users
75
- get the link from the backend's read-only `portal.status`, else "ask an
76
- administrator". Never write `AppPortal.json` or `@appconf/<package>`, never add
77
- a backend route that sets the alias, and never relay QuickConnect responses or
78
- build QuickConnect links. A portal needs os_min_ver 7.1-42661 or later, and
79
- `add portal` raises a lower one. Below DSM 7.2 the executor registers the
80
- entry: there is no alias wizard step, and an administrator enables the entry
81
- from the portal panel (`ctx.portal.register()`), which the executor's approved
82
- `portal.register` operation carries out. From 7.2 the SPK registers it as a
83
- DSM resource. Beyond that, support is detected (SYNO.API.Info), not
84
- version-gated.
65
+ From DSM 7.2-64570, Apps can declare `portal` without an executor. DSM's
66
+ web-config resource registers the entry, and INFO
67
+ `instuninst_restart_services="nginx.service"` asks DSM to reload nginx after
68
+ install, upgrade and uninstall. The reload is asynchronous; package hooks stay
69
+ unprivileged and never reload nginx themselves. Schema-2 Compose/gateway Apps
70
+ may also use a top-level `portal`; legacy `integration.web.portal` is unsupported.
71
+
72
+ `synopkgland add portal` adds `portal: {}` (or set
73
+ `portal: {alias: my-app}` in dsm-package.yaml). Without an executor, set
74
+ `os_min_ver` to 7.2-64570 or later before adding Portal; the command refuses a
75
+ lower target and never raises it silently. With an executor, a portal needs
76
+ `os_min_ver` 7.1-42661 or later. Below DSM 7.2 the executor registers the entry:
77
+ DSM 7.1 rejects the unsigned web-config worker, there is no alias wizard step,
78
+ and an administrator enables it through `ctx.portal.register()` after executor
79
+ approval. From DSM 7.2, the SPK registers it as a DSM resource.
80
+
81
+ Read `synopkgland skill show portal` before changing it. The install wizard only
82
+ proposes the alias; an administrator activates it in the App's portal panel
83
+ (`ctx.portal`, DSM's own `SYNO.Core.AppPortal` in the admin session) or Control
84
+ Panel > Login Portal > Applications. Other signed-in users get the link from
85
+ read-only `portal.status`, else "ask an administrator". Never write
86
+ `AppPortal.json` or `@appconf/<package>`, never add a backend route that sets
87
+ the alias, and never relay QuickConnect responses or build QuickConnect links.
88
+ Beyond the packaging floor, support is detected with SYNO.API.Info.
85
89
 
86
90
  ### Login page look (`login_style`)
87
91
 
@@ -234,8 +238,9 @@ sweep forward the cancel instead of admitting it.
234
238
  repeats the original request on repeated IDs. Review it in the SDK's delegate
235
239
  package to adapt a real producer; show Saved only from a protected committed
236
240
  receipt.
237
- - Wizard support is measured on vt-nas DSM 7.4 via a session-only SSH local
238
- forward. This is not proof of other DSM releases or full writer/reboot behavior.
241
+ - Verify the wizard on your own DSM release over an SSH local forward. The
242
+ template ships the wizard contract; it is not proof of every DSM release or of
243
+ full writer/reboot behavior.
239
244
 
240
245
  ## Go backend
241
246
 
@@ -14,7 +14,7 @@ compose:
14
14
  web: {service: web, port: 8080, health: {path: /, status: 200}}
15
15
  network: {egress: isolated}
16
16
  identity_gateway:
17
- sdk: 0.15.2
17
+ sdk: 0.17.0
18
18
  mode: none
19
19
  listen: {http: 28000}
20
20
  # Web Apps default to new-page through the shared app-launch resolver.
@@ -297,23 +297,21 @@ refusal for unknown methods.
297
297
 
298
298
  ## DSM Application Portal link
299
299
 
300
- Only an App with the root executor (`rootOps` or `delegate`) can declare a
301
- portal; `synopkgland add root-ops portal` declares both. Uninstall then
302
- removes the App's Application Privileges rules and reloads nginx once DSM has
303
- removed the alias, so it stops answering. `synopkgland add portal` adds
304
- `portal: {}` to dsm-package.yaml so
305
- `https://<nas>/<alias>/` opens this app right after DSM sign-in. The install
306
- wizard proposes the alias (the package name in lowercase by default); an
307
- administrator turns it on from the app's portal panel or Control Panel >
308
- Login Portal > Applications. Ordinary users then see the link; until then
309
- they see "ask an administrator". DSM owns the setting: the backend's
310
- `portal.status` only reads it, and nothing in the package may write it. See
311
- `synopkgland skill show portal`.
312
-
313
- A portal needs DSM 7.3.2-86009 or later, so `add portal` raises a lower
314
- `os_min_ver` and build refuses one below it. DSM 7.1.1 rejects an unsigned
315
- package that registers a portal (Package Center error 320); 7.3.2 and 7.4
316
- install it; 7.2 is untested. To keep supporting older DSM, remove `portal:`.
300
+ From DSM 7.2-64570, an App can declare `portal` without an executor. Set
301
+ `os_min_ver` to that floor or later before `synopkgland add portal`; the command
302
+ refuses a lower no-privilege target instead of silently raising it. The command
303
+ adds `portal: {}` so `https://<nas>/<alias>/` opens the App after DSM sign-in.
304
+ The install wizard proposes the alias; an administrator applies it in the App
305
+ panel or Control Panel > Login Portal > Applications. Other users see the link
306
+ or "ask an administrator". DSM owns the setting; `portal.status` only reads it.
307
+
308
+ Every Portal SPK asks DSM to reload nginx through INFO
309
+ `instuninst_restart_services="nginx.service"` after install, upgrade and
310
+ uninstall. The reload is asynchronous; package hooks stay unprivileged.
311
+ DSM 7.1-42661 still needs an approved executor to register the entry, since its
312
+ web-config worker rejects unsigned packages. For that route, declare an
313
+ executor capability, for example `synopkgland add root-ops portal`. See
314
+ `synopkgland skill show portal` before changing Portal settings.
317
315
 
318
316
  The build also gives the portal a login page look: a neutral default
319
317
  wallpaper (its thumbnail is what Control Panel > Login Portal > Applications
@@ -378,6 +376,21 @@ account or other packages. An upgrade never deletes anything. Do not write your
378
376
  `synopkgland inspect` flags a deleting script that lacks the upgrade guard
379
377
  or deletes outside the package's own folders.
380
378
 
379
+ ## Shared Folder access
380
+
381
+ A `install.sharedFolder` in `dsm-package.yaml` lets the administrator pick a folder in the
382
+ install wizard, but DSM does not give the package account access to a picked folder. The
383
+ backend reports the real state: the `sharedfolder.status` operation (administrators only
384
+ unless you change `permissions.json`) returns each folder with what the App needs, what the
385
+ package account has (`none`, `read`, `write`) and a `state` (`ready`, `pending`, `missing`,
386
+ `ambiguous`). `cfg.SharedFolderPath(key)` returns the path or an error that names the
387
+ Control Panel step instead of failing on a file read. Keep `SharedFolderRequired` in
388
+ `cmd/app/main.go` equal to the permission in `dsm-package.yaml`. Set `StorageAdapter` to
389
+ apply a changed path (a re-picked folder, a moved volume) once; it runs only after access is
390
+ granted. The Vue template shows the `SharedFolderSettings` section, which offers
391
+ **Grant access** to an administrator and otherwise explains the Control Panel steps. See the
392
+ SDK's `docs/shared-folder.md` for the whole flow.
393
+
381
394
  ## Support matrix
382
395
 
383
396
  | | Status |
@@ -1,6 +1,6 @@
1
1
  Native Go package build dependencies
2
2
 
3
- github.com/synopackageland/syno-pkg-land-sdk v0.15.2 (MIT)
3
+ github.com/synopackageland/syno-pkg-land-sdk v0.17.0 (MIT)
4
4
 
5
5
  Copyright (c) 2026 synopkgland
6
6
 
@@ -20,6 +20,7 @@ import (
20
20
  "github.com/synopackageland/syno-pkg-land-sdk/dsm"
21
21
  "github.com/synopackageland/syno-pkg-land-sdk/filestation"
22
22
  standaloneidentity "github.com/synopackageland/syno-pkg-land-sdk/identity"
23
+ "github.com/synopackageland/syno-pkg-land-sdk/sharedfolder"
23
24
 
24
25
  appapi "__PACKAGE__/internal/api"
25
26
  "__PACKAGE__/internal/app"
@@ -100,6 +101,9 @@ func main() {
100
101
  DataDir: "/var/packages/" + packageID + "/var",
101
102
  Portal: portal != "",
102
103
  PortalExecutor: portal == "executor",
104
+ // install.sharedFolder in dsm-package.yaml: `synopkgland init --shared-folder`
105
+ // writes key "shared" with permission rw. Keep this equal to it.
106
+ SharedFolderRequired: map[string]sharedfolder.Access{"shared": sharedfolder.Write},
103
107
  }
104
108
  if _, err := app.ParseDelegateConfig(cfg); err != nil {
105
109
  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.15.2
7
+ require github.com/synopackageland/syno-pkg-land-sdk v0.17.0
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.15.2 h1:IeIe7pb9MnFBYKj/m5AtjCch+vgdDc7VbzQMBVSxM3A=
2
- github.com/synopackageland/syno-pkg-land-sdk v0.15.2/go.mod h1:msR4MhJQnVZ8NKLb0E/1K79ldZUvcOJmWhKq+gPEa+4=
1
+ github.com/synopackageland/syno-pkg-land-sdk v0.17.0 h1:DaYDHCDlukkXtTm+3m26p7pU86r298MTasKDrV/tRl4=
2
+ github.com/synopackageland/syno-pkg-land-sdk v0.17.0/go.mod h1:vixf5Lo4PssQDGU5zam0Mb5sFu9wPUP4Ck36BMAVbNc=
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=
@@ -20,6 +20,7 @@ import (
20
20
 
21
21
  "github.com/synopackageland/syno-pkg-land-sdk/api"
22
22
  "github.com/synopackageland/syno-pkg-land-sdk/filestation"
23
+ "github.com/synopackageland/syno-pkg-land-sdk/sharedfolder"
23
24
  )
24
25
 
25
26
  // Config is fixed at build time and startup, never taken from a request.
@@ -37,6 +38,14 @@ type Config struct {
37
38
  DataDir string
38
39
  // VolumeRoot is where /volumeN lives; empty means "/". Tests set it.
39
40
  VolumeRoot string
41
+ // SharedFolderRequired is the access each Shared Folder key needs. Keep it
42
+ // equal to install.sharedFolder.permission in dsm-package.yaml: ro is
43
+ // sharedfolder.Read, rw is sharedfolder.Write.
44
+ SharedFolderRequired map[string]sharedfolder.Access
45
+ // StorageAdapter, when set, hands a Shared Folder's path to the App's own
46
+ // storage settings (a Local storage, a library root): the first time the
47
+ // folder is ready, and again whenever its path changes. Make it idempotent.
48
+ StorageAdapter func(ctx context.Context, key, path string) error
40
49
  // Portal serves portal.status; PortalExecutor adds the executor's entry
41
50
  // below DSM 7.2. PortalConfig overrides DSM's saved AppPortal.json path in
42
51
  // tests.
@@ -49,6 +58,7 @@ func Routes(cfg Config) []api.Route {
49
58
  routes := cfg.delegateRoutes()
50
59
  routes = append(routes, []api.Route{
51
60
  api.Handle("app.status", cfg.status),
61
+ api.Handle("sharedfolder.status", cfg.sharedFolderStatus),
52
62
  api.Handle("greeting.get", cfg.getGreeting),
53
63
  api.Handle("greeting.set", cfg.setGreeting),
54
64
  }...)
@@ -59,7 +69,7 @@ func Routes(cfg Config) []api.Route {
59
69
  }
60
70
 
61
71
  func (cfg Config) status(_ context.Context, caller api.Caller, _ json.RawMessage) (any, error) {
62
- return cfg.withSharedFolders(map[string]any{
72
+ return map[string]any{
63
73
  "package": cfg.PackageID,
64
74
  "version": cfg.Version,
65
75
  "user": caller.User(),
@@ -68,7 +78,7 @@ func (cfg Config) status(_ context.Context, caller api.Caller, _ json.RawMessage
68
78
  "go_version": runtime.Version(),
69
79
  "platform": runtime.GOOS + "/" + runtime.GOARCH,
70
80
  "time": time.Now().UTC().Format(time.RFC3339),
71
- }), nil
81
+ }, nil
72
82
  }
73
83
 
74
84
  type greeting struct {
@@ -107,9 +107,10 @@ var (
107
107
  var sampleOutcome = map[string]string{}
108
108
 
109
109
  var sampleParams = map[string]string{
110
- "app.status": `{}`,
111
- "greeting.get": `{}`,
112
- "greeting.set": `{"text":"Hi"}`,
110
+ "app.status": `{}`,
111
+ "sharedfolder.status": `{}`,
112
+ "greeting.get": `{}`,
113
+ "greeting.set": `{"text":"Hi"}`,
113
114
  }
114
115
 
115
116
  func TestEveryRouteHasSampleParamsAndNoneIsPublic(t *testing.T) {
@@ -1,144 +1,54 @@
1
1
  package app
2
2
 
3
3
  import (
4
+ "context"
4
5
  "encoding/json"
5
- "errors"
6
- "fmt"
7
- "io/fs"
8
- "os"
9
- "path/filepath"
10
- "sort"
11
- "strings"
12
- "syscall"
13
- )
14
-
15
- // The install wizard stores the chosen Shared Folder's *name* in
16
- // <DataDir>/config.json (see dsm-package.yaml install.sharedFolder). The path
17
- // is resolved on every call, so a Shared Folder moved to another volume keeps
18
- // working. config.json is written by the package's postinst/postupgrade, not
19
- // by the app.
20
6
 
21
- var (
22
- ErrSharedFolderNotFound = errors.New("shared folder not found on any volume")
23
- ErrSharedFolderAmbiguous = errors.New("shared folder name matches more than one volume")
7
+ "github.com/synopackageland/syno-pkg-land-sdk/api"
8
+ "github.com/synopackageland/syno-pkg-land-sdk/sharedfolder"
24
9
  )
25
10
 
26
- // SharedFolder says where one configured Shared Folder is right now and what
27
- // the package account may do there.
28
- type SharedFolder struct {
29
- Key string `json:"key"`
30
- Name string `json:"name"`
31
- Path string `json:"path,omitempty"`
32
- Exists bool `json:"exists"`
33
- Readable bool `json:"readable"`
34
- Writable bool `json:"writable"`
35
- Error string `json:"error,omitempty"`
36
- }
37
-
38
- func validShareName(name string) bool {
39
- return name != "" && !strings.ContainsAny(name, "/\\\x00") && !strings.Contains(name, "..") &&
40
- !strings.HasPrefix(name, "@") && !strings.HasPrefix(name, ".")
41
- }
42
-
43
- // ReadSharedFolders returns the Shared Folder names the installer stored, by
44
- // key. A package without config.json has none.
45
- func ReadSharedFolders(dataDir string) (map[string]string, error) {
46
- data, err := os.ReadFile(filepath.Join(dataDir, "config.json"))
47
- if errors.Is(err, fs.ErrNotExist) {
48
- return nil, nil
49
- }
50
- if err != nil {
51
- return nil, err
52
- }
53
- var cfg struct {
54
- Schema int `json:"schema"`
55
- SharedFolders map[string]string `json:"shared_folders"`
56
- }
57
- if err := json.Unmarshal(data, &cfg); err != nil {
58
- return nil, fmt.Errorf("config.json: %w", err)
59
- }
60
- if cfg.Schema != 1 {
61
- return nil, fmt.Errorf("config.json: unsupported schema %d", cfg.Schema)
62
- }
63
- for key, name := range cfg.SharedFolders {
64
- if !validShareName(name) {
65
- return nil, fmt.Errorf("config.json: invalid shared folder name for %q", key)
66
- }
67
- }
68
- return cfg.SharedFolders, nil
69
- }
70
-
71
- // ResolveSharedFolder finds /volumeN/<name> under root ("/" on a NAS).
72
- func ResolveSharedFolder(root, name string) (string, error) {
73
- if !validShareName(name) {
74
- return "", fmt.Errorf("invalid shared folder name %q", name)
75
- }
76
- volumes, err := filepath.Glob(filepath.Join(root, "volume[0-9]*"))
77
- if err != nil {
78
- return "", err
79
- }
80
- var found []string
81
- for _, volume := range volumes {
82
- path := filepath.Join(volume, name)
83
- if info, err := os.Stat(path); err == nil && info.IsDir() {
84
- found = append(found, path)
85
- }
86
- }
87
- switch len(found) {
88
- case 0:
89
- return "", ErrSharedFolderNotFound
90
- case 1:
91
- return found[0], nil
92
- }
93
- return "", ErrSharedFolderAmbiguous
11
+ // The install wizard stores the chosen Shared Folder's *name* in
12
+ // <DataDir>/config.json (see dsm-package.yaml install.sharedFolder). DSM does
13
+ // not give the package account access to a folder chosen at install time: an
14
+ // administrator grants it in Control Panel (or with one click in the settings
15
+ // page). The SDK's sharedfolder package measures what the account may do, so
16
+ // this route, the settings page and any daemon read one state.
17
+
18
+ // sharedFolders is the SDK's view of this package's Shared Folders.
19
+ func (cfg Config) sharedFolders() sharedfolder.Config {
20
+ return sharedfolder.Config{DataDir: cfg.DataDir, Root: cfg.VolumeRoot, Required: cfg.SharedFolderRequired}
94
21
  }
95
22
 
96
- const (
97
- accessRead = 0x4 // R_OK
98
- accessWrite = 0x2 // W_OK
99
- accessExec = 0x1 // X_OK
100
- )
101
-
102
- // SharedFolders reports every configured Shared Folder, sorted by key. The
103
- // error is a corrupt config.json.
104
- func (cfg Config) SharedFolders() ([]SharedFolder, error) {
105
- names, err := ReadSharedFolders(cfg.DataDir)
23
+ // sharedFolderStatus answers the settings page: each folder, what the package
24
+ // account has and what the App declared it needs, and whether an
25
+ // administrator still has to grant. Administrators only, as the default
26
+ // permissions say; list the method in internal/api/permissions.json to let
27
+ // every DSM user read it.
28
+ func (cfg Config) sharedFolderStatus(ctx context.Context, _ api.Caller, _ json.RawMessage) (any, error) {
29
+ status, err := cfg.sharedFolders().Status()
106
30
  if err != nil {
107
31
  return nil, err
108
32
  }
109
- root := cfg.VolumeRoot
110
- if root == "" {
111
- root = "/"
112
- }
113
- keys := make([]string, 0, len(names))
114
- for key := range names {
115
- keys = append(keys, key)
116
- }
117
- sort.Strings(keys)
118
- folders := make([]SharedFolder, 0, len(keys))
119
- for _, key := range keys {
120
- folder := SharedFolder{Key: key, Name: names[key]}
121
- path, err := ResolveSharedFolder(root, folder.Name)
122
- if err != nil {
123
- folder.Error = err.Error()
124
- } else {
125
- folder.Path, folder.Exists = path, true
126
- folder.Readable = syscall.Access(path, accessRead|accessExec) == nil
127
- folder.Writable = syscall.Access(path, accessWrite|accessExec) == nil
33
+ // The App's own storage follows a changed path (another folder chosen,
34
+ // the share moved to another volume) here, the first time anything reads it.
35
+ if cfg.StorageAdapter != nil {
36
+ for _, folder := range status.Folders {
37
+ if folder.State == sharedfolder.Ready {
38
+ if _, err := cfg.sharedFolders().Sync(folder.Key, func(path string) error {
39
+ return cfg.StorageAdapter(ctx, folder.Key, path)
40
+ }); err != nil {
41
+ return nil, sharedfolder.APIError(err)
42
+ }
43
+ }
128
44
  }
129
- folders = append(folders, folder)
130
45
  }
131
- return folders, nil
46
+ return status, nil
132
47
  }
133
48
 
134
- // withSharedFolders adds the configured Shared Folders to an app.status
135
- // answer; a package that configures none is answered exactly as before.
136
- func (cfg Config) withSharedFolders(status map[string]any) map[string]any {
137
- folders, err := cfg.SharedFolders()
138
- if err != nil {
139
- status["shared_folders_error"] = err.Error()
140
- } else if len(folders) > 0 {
141
- status["shared_folders"] = folders
142
- }
143
- return status
49
+ // SharedFolderPath is what a daemon calls before it touches the folder: the
50
+ // path once the account has the declared access, otherwise an error naming
51
+ // what to grant, never a failed file read.
52
+ func (cfg Config) SharedFolderPath(key string) (string, error) {
53
+ return cfg.sharedFolders().Require(key)
144
54
  }
@@ -1,10 +1,13 @@
1
1
  package app
2
2
 
3
3
  import (
4
+ "context"
4
5
  "errors"
5
6
  "os"
6
7
  "path/filepath"
7
8
  "testing"
9
+
10
+ "github.com/synopackageland/syno-pkg-land-sdk/sharedfolder"
8
11
  )
9
12
 
10
13
  func writeConfig(t *testing.T, dir, body string) {
@@ -14,145 +17,129 @@ func writeConfig(t *testing.T, dir, body string) {
14
17
  }
15
18
  }
16
19
 
17
- func TestReadSharedFoldersWithoutConfigIsEmpty(t *testing.T) {
18
- got, err := ReadSharedFolders(t.TempDir())
19
- if err != nil || len(got) != 0 {
20
- t.Fatalf("got %v %v", got, err)
20
+ // sharedFixture has /volume1/Photos with the mode, chosen for key "media".
21
+ func sharedFixture(t *testing.T, mode os.FileMode) Config {
22
+ t.Helper()
23
+ cfg := testConfig(t)
24
+ cfg.VolumeRoot = t.TempDir()
25
+ dir := filepath.Join(cfg.VolumeRoot, "volume1", "Photos")
26
+ if err := os.MkdirAll(dir, 0o755); err != nil {
27
+ t.Fatal(err)
21
28
  }
29
+ if err := os.Chmod(dir, mode); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ t.Cleanup(func() { os.Chmod(dir, 0o755) })
33
+ writeConfig(t, cfg.DataDir, `{"schema":1,"shared_folders":{"media":"Photos"}}`)
34
+ cfg.SharedFolderRequired = map[string]sharedfolder.Access{"media": sharedfolder.Write}
35
+ return cfg
22
36
  }
23
37
 
24
- func TestReadSharedFoldersReturnsNamesByKey(t *testing.T) {
25
- dir := t.TempDir()
26
- writeConfig(t, dir, `{"schema":1,"shared_folders":{"media":"Family Photos"}}`)
27
- got, err := ReadSharedFolders(dir)
28
- if err != nil || got["media"] != "Family Photos" || len(got) != 1 {
29
- t.Fatalf("got %v %v", got, err)
38
+ func skipRoot(t *testing.T) {
39
+ t.Helper()
40
+ if os.Geteuid() == 0 {
41
+ t.Skip("root ignores directory modes")
30
42
  }
31
43
  }
32
44
 
33
- func TestReadSharedFoldersRefusesUnknownSchemaAndBadNames(t *testing.T) {
34
- for _, body := range []string{
35
- `{"schema":2,"shared_folders":{"media":"x"}}`,
36
- `{"schema":1,"shared_folders":{"media":"a/b"}}`,
37
- `{"schema":1,"shared_folders":{"media":".."}}`,
38
- `{"schema":1,"shared_folders":{"media":"@appdata"}}`,
39
- `{"schema":1,"shared_folders":{"media":""}}`,
40
- `not json`,
41
- } {
42
- dir := t.TempDir()
43
- writeConfig(t, dir, body)
44
- if _, err := ReadSharedFolders(dir); err == nil {
45
- t.Errorf("%s: want an error", body)
46
- }
45
+ func TestSharedFolderStatusIsEmptyWithoutAFolder(t *testing.T) {
46
+ code, out := call(t, testConfig(t), alice, "sharedfolder.status", `{}`)
47
+ if code != "" || len(out["data"].(map[string]any)["folders"].([]any)) != 0 {
48
+ t.Fatalf("%q %v", code, out)
47
49
  }
48
50
  }
49
51
 
50
- func TestResolveSharedFolderFindsTheNameOnItsVolume(t *testing.T) {
51
- root := t.TempDir()
52
- for _, d := range []string{"volume1/other", "volume2/Photos", "volume2/@appdata"} {
53
- if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
54
- t.Fatal(err)
52
+ func TestSharedFolderStatusReportsGrantedAndPending(t *testing.T) {
53
+ skipRoot(t)
54
+ for _, c := range []struct {
55
+ mode os.FileMode
56
+ state string
57
+ }{{0o755, "ready"}, {0o555, "pending"}} {
58
+ cfg := sharedFixture(t, c.mode)
59
+ code, out := call(t, cfg, alice, "sharedfolder.status", `{}`)
60
+ if code != "" {
61
+ t.Fatalf("%q %v", code, out)
62
+ }
63
+ row := out["data"].(map[string]any)["folders"].([]any)[0].(map[string]any)
64
+ if row["state"] != c.state || row["required"] != "write" || row["name"] != "Photos" || row["path"] != filepath.Join(cfg.VolumeRoot, "volume1", "Photos") {
65
+ t.Fatalf("mode %o: %v", c.mode, row)
55
66
  }
56
- }
57
- got, err := ResolveSharedFolder(root, "Photos")
58
- if err != nil || got != filepath.Join(root, "volume2", "Photos") {
59
- t.Fatalf("got %q %v", got, err)
60
67
  }
61
68
  }
62
69
 
63
- func TestResolveSharedFolderFollowsAMovedShare(t *testing.T) {
64
- root := t.TempDir()
65
- os.MkdirAll(filepath.Join(root, "volume1", "Photos"), 0o755)
66
- first, _ := ResolveSharedFolder(root, "Photos")
67
- os.Rename(filepath.Join(root, "volume1", "Photos"), filepath.Join(root, "Photos.tmp"))
68
- os.MkdirAll(filepath.Join(root, "volume2"), 0o755)
69
- os.Rename(filepath.Join(root, "Photos.tmp"), filepath.Join(root, "volume2", "Photos"))
70
- second, err := ResolveSharedFolder(root, "Photos")
71
- if err != nil || first == second || second != filepath.Join(root, "volume2", "Photos") {
72
- t.Fatalf("%q -> %q %v", first, second, err)
70
+ func TestSharedFolderStatusIsAdministratorOnlyByDefault(t *testing.T) {
71
+ cfg := sharedFixture(t, 0o755)
72
+ if code, _ := call(t, cfg, bob, "sharedfolder.status", `{}`); code == "" {
73
+ t.Fatal("a non-administrator read the status")
73
74
  }
74
75
  }
75
76
 
76
- func TestResolveSharedFolderErrors(t *testing.T) {
77
- root := t.TempDir()
78
- if _, err := ResolveSharedFolder(root, "Missing"); !errors.Is(err, ErrSharedFolderNotFound) {
79
- t.Fatalf("missing: %v", err)
80
- }
81
- os.MkdirAll(filepath.Join(root, "volume1", "Dup"), 0o755)
82
- os.MkdirAll(filepath.Join(root, "volume2", "Dup"), 0o755)
83
- if _, err := ResolveSharedFolder(root, "Dup"); !errors.Is(err, ErrSharedFolderAmbiguous) {
84
- t.Fatalf("ambiguous: %v", err)
85
- }
86
- for _, bad := range []string{"", "..", "a/b", "@appdata", ".x"} {
87
- if _, err := ResolveSharedFolder(root, bad); err == nil {
88
- t.Errorf("%q: want an error", bad)
89
- }
77
+ func TestAppStatusNoLongerCarriesSharedFolders(t *testing.T) {
78
+ cfg := sharedFixture(t, 0o755)
79
+ _, out := call(t, cfg, alice, "app.status", `{}`)
80
+ data := out["data"].(map[string]any)
81
+ if _, present := data["shared_folders"]; present {
82
+ t.Fatalf("app.status: %v", data)
90
83
  }
91
84
  }
92
85
 
93
- func TestResolveSharedFolderIgnoresAFileOfThatName(t *testing.T) {
94
- root := t.TempDir()
95
- os.MkdirAll(filepath.Join(root, "volume1"), 0o755)
96
- os.WriteFile(filepath.Join(root, "volume1", "Notes"), nil, 0o644)
97
- if _, err := ResolveSharedFolder(root, "Notes"); !errors.Is(err, ErrSharedFolderNotFound) {
98
- t.Fatalf("got %v", err)
86
+ func TestSharedFolderStatusFailsOnACorruptConfig(t *testing.T) {
87
+ cfg := testConfig(t)
88
+ writeConfig(t, cfg.DataDir, `not json`)
89
+ if code, _ := call(t, cfg, alice, "sharedfolder.status", `{}`); code != "internal" {
90
+ t.Fatalf("code %q", code)
99
91
  }
100
92
  }
101
93
 
102
- func TestStatusOmitsSharedFoldersWhenNoneConfigured(t *testing.T) {
103
- _, out := call(t, testConfig(t), alice, "app.status", `{}`)
104
- if _, present := out["data"].(map[string]any)["shared_folders"]; present {
105
- t.Fatalf("unexpected shared_folders: %v", out)
94
+ func TestStorageAdapterFollowsTheChosenFolderAndAMovedShare(t *testing.T) {
95
+ skipRoot(t)
96
+ cfg := sharedFixture(t, 0o755)
97
+ var applied []string
98
+ cfg.StorageAdapter = func(_ context.Context, key, path string) error {
99
+ applied = append(applied, key+"="+path)
100
+ return nil
101
+ }
102
+ first := filepath.Join(cfg.VolumeRoot, "volume1", "Photos")
103
+ for i := 0; i < 2; i++ {
104
+ if code, _ := call(t, cfg, alice, "sharedfolder.status", `{}`); code != "" {
105
+ t.Fatal(code)
106
+ }
106
107
  }
107
- }
108
-
109
- func TestStatusReportsEachConfiguredSharedFolder(t *testing.T) {
110
- cfg := testConfig(t)
111
- cfg.VolumeRoot = t.TempDir()
112
- os.MkdirAll(filepath.Join(cfg.VolumeRoot, "volume1", "Photos"), 0o755)
113
- writeConfig(t, cfg.DataDir, `{"schema":1,"shared_folders":{"media":"Photos","gone":"Missing"}}`)
114
- _, out := call(t, cfg, alice, "app.status", `{}`)
115
- rows := out["data"].(map[string]any)["shared_folders"].([]any)
116
- if len(rows) != 2 {
117
- t.Fatalf("rows: %v", rows)
108
+ if len(applied) != 1 || applied[0] != "media="+first {
109
+ t.Fatalf("first reads apply once: %v", applied)
118
110
  }
119
- byKey := map[string]map[string]any{}
120
- for _, r := range rows {
121
- m := r.(map[string]any)
122
- byKey[m["key"].(string)] = m
111
+ second := filepath.Join(cfg.VolumeRoot, "volume2", "Photos")
112
+ if err := os.MkdirAll(filepath.Dir(second), 0o755); err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ if err := os.Rename(first, second); err != nil {
116
+ t.Fatal(err)
123
117
  }
124
- media, gone := byKey["media"], byKey["gone"]
125
- if media["name"] != "Photos" || media["exists"] != true || media["readable"] != true || media["writable"] != true || media["path"] != filepath.Join(cfg.VolumeRoot, "volume1", "Photos") {
126
- t.Fatalf("media: %v", media)
118
+ if code, _ := call(t, cfg, alice, "sharedfolder.status", `{}`); code != "" {
119
+ t.Fatal(code)
127
120
  }
128
- if gone["exists"] != false || gone["readable"] != false || gone["writable"] != false || gone["error"] == nil || gone["path"] != nil {
129
- t.Fatalf("gone: %v", gone)
121
+ if len(applied) != 2 || applied[1] != "media="+second {
122
+ t.Fatalf("a moved share applies the new path: %v", applied)
130
123
  }
131
124
  }
132
125
 
133
- func TestStatusReportsAFolderThePackageAccountCannotWrite(t *testing.T) {
134
- if os.Geteuid() == 0 {
135
- t.Skip("root ignores directory modes")
136
- }
137
- cfg := testConfig(t)
138
- cfg.VolumeRoot = t.TempDir()
139
- dir := filepath.Join(cfg.VolumeRoot, "volume1", "Locked")
140
- os.MkdirAll(dir, 0o755)
141
- os.Chmod(dir, 0o555)
142
- t.Cleanup(func() { os.Chmod(dir, 0o755) })
143
- writeConfig(t, cfg.DataDir, `{"schema":1,"shared_folders":{"media":"Locked"}}`)
144
- _, out := call(t, cfg, alice, "app.status", `{}`)
145
- row := out["data"].(map[string]any)["shared_folders"].([]any)[0].(map[string]any)
146
- if row["exists"] != true || row["readable"] != true || row["writable"] != false {
147
- t.Fatalf("row: %v", row)
126
+ func TestStorageAdapterWaitsForThePermission(t *testing.T) {
127
+ skipRoot(t)
128
+ cfg := sharedFixture(t, 0o555)
129
+ called := false
130
+ cfg.StorageAdapter = func(context.Context, string, string) error { called = true; return nil }
131
+ if code, _ := call(t, cfg, alice, "sharedfolder.status", `{}`); code != "" || called {
132
+ t.Fatalf("pending folder: code %q called %v", code, called)
148
133
  }
149
134
  }
150
135
 
151
- func TestStatusReportsACorruptConfigWithoutFailing(t *testing.T) {
152
- cfg := testConfig(t)
153
- writeConfig(t, cfg.DataDir, `not json`)
154
- code, out := call(t, cfg, alice, "app.status", `{}`)
155
- if code != "" || out["data"].(map[string]any)["shared_folders_error"] == nil {
156
- t.Fatalf("%q %v", code, out)
136
+ func TestSharedFolderPathNamesWhatToGrant(t *testing.T) {
137
+ skipRoot(t)
138
+ _, err := sharedFixture(t, 0o555).SharedFolderPath("media")
139
+ if !errors.Is(err, sharedfolder.ErrPermissionPending) {
140
+ t.Fatalf("got %v", err)
141
+ }
142
+ if path, err := sharedFixture(t, 0o755).SharedFolderPath("media"); err != nil || path == "" {
143
+ t.Fatalf("%q %v", path, err)
157
144
  }
158
145
  }
@@ -17,7 +17,7 @@ function render(ctx:unknown){
17
17
  }
18
18
  function button(root:HTMLElement,label:string){const b=[...root.querySelectorAll('button')].find(n=>n.textContent?.trim()===label);expect(b,label).toBeTruthy();return b!}
19
19
  test('the capability surface uses an explicit long-running facade without legacy calls',async()=>{
20
- const grant={id:'mine',key:'save',path:'/home/Exports',username:'normal1',lifetime:'until-revoked' as const}
20
+ const grant={id:'mine',key:'save',path:'/home/Exports',username:'alice',lifetime:'until-revoked' as const}
21
21
  const sdk={
22
22
  destination:vi.fn(async(key:string)=>({key,selection:{scope:'home',root:'/home',generation:0},destination:grant,status:'active' as const})),
23
23
  exportExample:vi.fn(async({job_id}:{key:string;job_id:string})=>({delegation_id:'mine',job_id})),
@@ -36,12 +36,12 @@ test('the capability surface uses an explicit long-running facade without legacy
36
36
  expect(call).not.toHaveBeenCalled()
37
37
  })
38
38
  test('the default surface binds the real routes and the session-only picker',async()=>{
39
- const grant={id:'g1',key:'save',path:'/home',username:'normal1',lifetime:'until-revoked'}
39
+ const grant={id:'g1',key:'save',path:'/home',username:'alice',lifetime:'until-revoked'}
40
40
  let selected=false
41
41
  const call=vi.fn(async(method:string,params?:unknown)=>{
42
42
  if(method==='delegate.status')return {state:'ready',disabled:false,lifetime:'until-revoked',destinations:['save']}
43
43
  if(method==='delegate.destination')return {key:'save',selection:{scope:'home',root:'/home',generation:0},destination:selected?grant:null,status:selected?'active':'missing'}
44
- if(method==='delegate.list')return {delegations:selected?[{delegation_id:'g1',destination:'save',path:'/home',subject:{username:'normal1'},lifetime:'until-revoked',revoked:false}]:[]}
44
+ if(method==='delegate.list')return {delegations:selected?[{delegation_id:'g1',destination:'save',path:'/home',subject:{username:'alice'},lifetime:'until-revoked',revoked:false}]:[]}
45
45
  if(method==='delegate.operations.list')return {operations:[]}
46
46
  if(method==='bg.browse')return {shares:[{name:'home',path:'/home',isdir:true}],offset:0,total:1}
47
47
  if(method==='delegate.select_destination'){selected=true;return grant}
@@ -176,7 +176,7 @@ func TestTheExecutorClientWaitsTheSDKsTimeout(t *testing.T) {
176
176
 
177
177
  // The executor client is the SDK's delegate.Local: it reads the package's
178
178
  // lifecycle mailbox, so a stopped or starting package answers APP_STOPPED or
179
- // APP_STARTING instead of EXECUTOR_UNAVAILABLE (t-0513).
179
+ // APP_STARTING instead of EXECUTOR_UNAVAILABLE.
180
180
  func TestTheExecutorClientReadsThePackagesLifecycleMailbox(t *testing.T) {
181
181
  cfg := testConfig(t)
182
182
  if got, want := cfg.executor().Mailbox, "/var/packages/"+cfg.PackageID+"/var/executor"; got != want {
@@ -52,7 +52,7 @@ The `native-go-jobs` overlay extends this **Operate** surface within the inherit
52
52
  - **Layout:** schedule name/state, owner, next run, missed runs and warnings stack above a grouped, wrapping action row. Long names and copy wrap within the available width. Keep the inherited vertical scrolling at the minimum window size; do not squeeze schedules into one viewport. The labeled select and consent checkbox retain visible keyboard focus using existing theme tokens.
53
53
  - **Errors and cleanup:** danger alerts map known error codes to localized safe copy, with a localized fallback rather than raw server text. The catalogs cover nine locales. Preserve per-mount disposal: stop polling, dispose the picker, revoke result download URLs and prevent pending responses from restoring disposed state. Deletion confirmation explains that queued jobs cancel, running jobs drain and finished results remain.
54
54
 
55
- Evidence: `.herdr-project/syno-package-land-sdk-t-0129/library/ui-desktop.png` and `ui-minimum.png` are local component-fixture captures. The finish review disposition is **ship**, with all three material findings resolved. A fresh `normal1` NAS browser readback passed before the final UI adjustments; these captures validate the final adjustments locally and do not establish final DSM visual acceptance.
55
+ This layout is checked against the template's local component fixtures at the desktop size and at the DSM minimum window size, including the states and recovery copy above. That validates the component contract only; visual acceptance on a specific DSM release is the App developer's own check.
56
56
 
57
57
  ## Do's and Don'ts
58
58
 
@@ -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.15.2",
11
- "@synopackageland/ui": "0.12.0",
10
+ "@synopackageland/sdk": "0.17.0",
11
+ "@synopackageland/ui": "0.12.1",
12
12
  "vue": "3.5.43"
13
13
  },
14
14
  "devDependencies": {
@@ -1152,9 +1152,9 @@
1152
1152
  }
1153
1153
  },
1154
1154
  "node_modules/@synopackageland/sdk": {
1155
- "version": "0.15.2",
1156
- "resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.15.2.tgz",
1157
- "integrity": "sha512-am3V3HcYj00wj0Zs1XXDU6aNMh6KlXTkwqklIoIaCd7QxozDec53Nyj6jBRD/nkB7m7MDHQp+4habkRtrEF5sw==",
1155
+ "version": "0.17.0",
1156
+ "resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.17.0.tgz",
1157
+ "integrity": "sha512-3h7PjF55kJjKDJHZE9tbR0UxOgmxfGEnY9eKkpKPStdy8OiTOGjOlB298+YvJtkRBLiOZ13En10sa/ADbU9DnA==",
1158
1158
  "license": "MIT",
1159
1159
  "peerDependencies": {
1160
1160
  "@lucide/vue": "^1.48.0",
@@ -1171,9 +1171,9 @@
1171
1171
  }
1172
1172
  },
1173
1173
  "node_modules/@synopackageland/ui": {
1174
- "version": "0.12.0",
1175
- "resolved": "https://registry.npmjs.org/@synopackageland/ui/-/ui-0.12.0.tgz",
1176
- "integrity": "sha512-LGdkmceKcybJj4kvbwBZk5vewKepDi4vyGohkittpucnfOM9EOlCWkaWfWoZZ5MD0LQlwZf2ueydcrEOLq3R/A==",
1174
+ "version": "0.12.1",
1175
+ "resolved": "https://registry.npmjs.org/@synopackageland/ui/-/ui-0.12.1.tgz",
1176
+ "integrity": "sha512-nyHKefcyKWiaXpjsjFfxrgsH4RSDHgO71bOV8uGRf89Ch2kDapNTfv42RtR2idIfNTt7A3/gKw8eUlyRn9VJDQ==",
1177
1177
  "license": "MIT",
1178
1178
  "dependencies": {
1179
1179
  "@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.15.2",
12
- "@synopackageland/ui": "0.12.0",
11
+ "@synopackageland/sdk": "0.17.0",
12
+ "@synopackageland/ui": "0.12.1",
13
13
  "vue": "3.5.43"
14
14
  },
15
15
  "devDependencies": {
@@ -1,8 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
3
3
  import {Button, Callout, Form, FormActions, FormItem, KeyValueRow, SettingsSection, Textarea} from '@synopackageland/ui'
4
- import {bridgeErrorCopy, type AppStatus, type Context, type ShellPage} from '@synopackageland/sdk'
5
- import {AppShell, BackgroundPanel, FilesPage, LoginPage, PortalSettings, createSession, shellPages} from '@synopackageland/sdk/vue'
4
+ import {bridgeErrorCopy, sharedFolderAccessFor, type AppStatus, type Context, type ShellPage} from '@synopackageland/sdk'
5
+ import {AppShell, BackgroundPanel, FilesPage, LoginPage, PortalSettings, SharedFolderSettings, createSession, shellPages} from '@synopackageland/sdk/vue'
6
6
  import {appCopy} from './i18n'
7
7
  import CapabilityPanels from './CapabilityPanels.vue'
8
8
  import ExecutorGate, {holdsPages} from './ExecutorGate.vue'
@@ -10,6 +10,9 @@ const props = defineProps<{ctx: Context}>()
10
10
  const login = createSession(props.ctx)
11
11
  // Pages ExecutorGate holds need no notice above them that repeats its approval.
12
12
  const shellCtx = holdsPages ? {...props.ctx, executor: undefined} : props.ctx
13
+ // Per-folder Shared Folder access for the package account; the section stays hidden unless
14
+ // the install wizard stored a folder and the status can be read (administrators only).
15
+ const sharedFolders = sharedFolderAccessFor(props.ctx)
13
16
  const copy = computed(() => appCopy(props.ctx.locale))
14
17
  const app = ref<AppStatus>()
15
18
  const busy = ref(false)
@@ -40,10 +43,24 @@ async function refresh() {
40
43
  const early = !login.ready.value
41
44
  const call: typeof login.call = early ? (method, params) => props.ctx.call(method, params) : login.call
42
45
  busy.value = true; error.value = ''
46
+ // An administrator DSM names up front has the greeting read beside the status.
47
+ const startupBatch = props.ctx.user?.isAdmin === true ? props.ctx.batch : undefined
48
+ const read = !startupBatch && props.ctx.user?.isAdmin ? call<{text:string}>('greeting.get') : undefined
49
+ read?.catch(() => {})
43
50
  try {
44
- const a = await call<AppStatus>('app.status')
45
- if(!disposed) app.value = a
46
- if(a.admin) {const greeting = await call<{text:string}>('greeting.get'); if(!disposed) text.value = greeting.text}
51
+ if(startupBatch) {
52
+ const batch = early ? startupBatch : login.batch
53
+ const results = await batch([{method:'app.status'},{method:'greeting.get'}])
54
+ const status = results[0]!, greeting = results[1]!
55
+ if(!status.success) throw status.error
56
+ if(!disposed) app.value = status.data as AppStatus
57
+ if(!greeting.success) throw greeting.error
58
+ if(!disposed) text.value = (greeting.data as {text:string}).text
59
+ } else {
60
+ const a = await call<AppStatus>('app.status')
61
+ if(!disposed) app.value = a
62
+ if(a.admin) {const greeting = await (read ?? call<{text:string}>('greeting.get')); if(!disposed) text.value = greeting.text}
63
+ }
47
64
  } catch(e) {if(!disposed && !(early && login.enabled.value)) error.value = message(e)}
48
65
  finally {if(!disposed) {busy.value=false; if(again) {again=false; void refresh()}}}
49
66
  }
@@ -95,6 +112,7 @@ onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose();text.value=''})
95
112
  <p v-else-if="error" role="status">{{ copy.statusError }}</p>
96
113
  </SettingsSection>
97
114
  <PortalSettings v-if="ctx.portal && app" :key="portalKey" :portal="ctx.portal" />
115
+ <SharedFolderSettings v-if="app?.admin" :key="portalKey" :shared="sharedFolders" :locale="ctx.locale" />
98
116
  <SettingsSection v-if="app?.admin" :title="copy.greeting" :collapsible="false">
99
117
  <Form direction="vertical" @submit.prevent="save"><FormItem :label="copy.greeting"><Textarea v-model="text" :disabled="busy" /></FormItem>
100
118
  <FormActions><Button variant="primary" :disabled="busy || !text.trim()" @click="save">{{ copy.save }}</Button></FormActions>
@@ -88,6 +88,19 @@ test('the app renders and loads status while the session check is still pending'
88
88
  expect([...root.querySelectorAll('[data-app-status] [data-app-value]')].map(node=>node.textContent)).toEqual(['Eager','admin','1'])
89
89
  }finally{app.unmount();root.remove()}
90
90
  })
91
+ test('an administrator DSM names up front has the greeting read beside the status',async()=>{
92
+ const call=vi.fn(async(method:string)=>{
93
+ if(method==='app.status')return new Promise(()=>{})
94
+ if(method==='greeting.get')return {text:'Hello Early'}
95
+ throw {code:'unknown_method'}
96
+ })
97
+ const root=document.createElement('div');document.body.append(root)
98
+ const app=mount(root,dsmWindow({call,user:{name:'admin',isAdmin:true}}))
99
+ try{
100
+ await settle()
101
+ expect(call.mock.calls.map(([method])=>method)).toContain('greeting.get')
102
+ }finally{app.unmount();root.remove()}
103
+ })
91
104
  test('a status request that raced the session check is retried once the session is confirmed',async()=>{
92
105
  let session=false,rejectEarly:(e:unknown)=>void=()=>{}
93
106
  const call=vi.fn(async(method:string)=>{
@@ -219,3 +232,36 @@ test('a needed sign-in takes the shell page in place of the App pages, inside th
219
232
  expect(form!.closest('[data-ui-app]')).not.toBeNull()
220
233
  }finally{app.unmount();root.remove()}
221
234
  })
235
+ test('an administrator sees a pending Shared Folder with the grant step; a plain app shows no section',async()=>{
236
+ const plain=fixture('NoShare')
237
+ const folder={key:'shared',name:'Media',exists:true,required:'write',granted:'read',state:'pending'}
238
+ const call=vi.fn(async(method:string)=>{
239
+ if(method==='app.status')return {package:'Share',version:'1',user:'admin',admin:true}
240
+ if(method==='greeting.get')return {text:'Hi'}
241
+ if(method==='sharedfolder.status')return {account:'Share',folders:[folder]}
242
+ throw {code:'unknown_method'}
243
+ })
244
+ const ctx=dsmWindow({call,user:{name:'admin',isAdmin:true}})
245
+ const root=document.createElement('div');document.body.append(root)
246
+ const app=mount(root,ctx)
247
+ try{
248
+ await settle()
249
+ expect(plain.root.textContent).not.toContain('Shared Folders')
250
+ expect(root.querySelector('[data-shared-folder-state="pending"]')).not.toBeNull()
251
+ expect(root.textContent).toContain('System internal user')
252
+ expect(root.textContent).toContain('Ask an administrator')
253
+ }finally{app.unmount();root.remove();plain.close()}
254
+ })
255
+
256
+ test('batches startup reads for a known administrator', async () => {
257
+ const calls: string[]=[]
258
+ const batch=vi.fn(async () => [{success:true as const,data:{package:'Batch',version:'1',user:'admin',admin:true}},{success:true as const,data:{text:'Hello Batch'}}])
259
+ const ctx=dsmWindow({user:{name:'admin',isAdmin:true},batch:batch as NonNullable<Context['batch']>,call:async <T,>(method:string)=>{calls.push(method);if(method==='identity.status')return {enabled:false} as T;throw {code:'unknown_method'}}})
260
+ const root=document.createElement('div');document.body.append(root)
261
+ const app=mount(root,ctx)
262
+ await settle()
263
+ expect(batch).toHaveBeenCalledWith([{method:'app.status'},{method:'greeting.get'}])
264
+ expect(calls).not.toContain('app.status');expect(calls).not.toContain('greeting.get')
265
+ expect((root.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Hello Batch')
266
+ app.unmount();root.remove()
267
+ })
@@ -49,7 +49,7 @@ backend:
49
49
  runtime: node
50
50
  dir: backend/app
51
51
  entry: main.js
52
- sdk: 0.15.2
52
+ sdk: 0.17.0
53
53
  node: { package: Node.js_v20 }
54
54
 
55
55
  ui:
@@ -47,7 +47,7 @@ backend:
47
47
  runtime: python
48
48
  dir: backend/app
49
49
  entry: main.py
50
- sdk: 0.15.2
50
+ sdk: 0.17.0
51
51
 
52
52
  ui:
53
53
  # Non-UI-SDK frontends open a full page; Portal is a separate opt-in.
@@ -104,8 +104,8 @@ dependencies = [
104
104
 
105
105
  [[package]]
106
106
  name = "syno-pkg-land-app"
107
- version = "0.15.2"
108
- source = "git+https://github.com/synopackageland/syno-pkg-land-sdk?tag=v0.15.2#be41eaef61f2a4e67b77902efd5d86abfc38cd63"
107
+ version = "0.17.0"
108
+ source = "git+https://github.com/synopackageland/syno-pkg-land-sdk?tag=v0.17.0#91fa899beec935af9668a04c24ccd64b0d03b00b"
109
109
  dependencies = [
110
110
  "libc",
111
111
  "serde",
@@ -9,7 +9,7 @@ libc = "0.2"
9
9
  serde = { version = "1", features = ["derive"] }
10
10
  serde_json = "1"
11
11
  # The SDK version is the tag; `synopkgland check` reads it from Cargo.lock.
12
- syno-pkg-land-app = { git = "https://github.com/synopackageland/syno-pkg-land-sdk", tag = "v0.15.2" }
12
+ syno-pkg-land-app = { git = "https://github.com/synopackageland/syno-pkg-land-sdk", tag = "v0.17.0" }
13
13
 
14
14
  [profile.release]
15
15
  # A handler panic becomes an "internal" reply; abort would end the App without one.