@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 +9 -3
- package/package.json +1 -1
- package/templates/_shared/AGENTS.md +27 -22
- package/templates/compose/dsm-package.yaml +1 -1
- package/templates/native-go/README.md +30 -17
- package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
- package/templates/native-go/backend/cmd/app/main.go +4 -0
- package/templates/native-go/backend/go.mod +1 -1
- package/templates/native-go/backend/go.sum +2 -2
- package/templates/native-go/backend/internal/app/routes.go +12 -2
- package/templates/native-go/backend/internal/app/routes_test.go +4 -3
- package/templates/native-go/backend/internal/app/shared_folder.go +37 -127
- package/templates/native-go/backend/internal/app/shared_folder_test.go +95 -108
- package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +3 -3
- package/templates/native-go-root-ops/backend/internal/app/executor_test.go +1 -1
- package/templates/native-go-vue/frontend/DESIGN.md +1 -1
- package/templates/native-go-vue/frontend/package-lock.json +8 -8
- package/templates/native-go-vue/frontend/package.json +2 -2
- package/templates/native-go-vue/frontend/src/App.vue +23 -5
- package/templates/native-go-vue/frontend/test/apps.test.ts +46 -0
- package/templates/native-node/dsm-package.yaml +1 -1
- package/templates/native-python/dsm-package.yaml +1 -1
- package/templates/native-rust/backend/Cargo.lock +2 -2
- package/templates/native-rust/backend/Cargo.toml +1 -1
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.
|
|
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.
|
|
46
|
-
`@synopackageland/ui` 0.12.0. SDK 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.
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
DSM
|
|
84
|
-
|
|
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
|
-
-
|
|
238
|
-
|
|
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.
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
`
|
|
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 |
|
|
@@ -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)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
github.com/synopackageland/syno-pkg-land-sdk v0.
|
|
2
|
-
github.com/synopackageland/syno-pkg-land-sdk v0.
|
|
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
|
|
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
|
-
}
|
|
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
|
-
"
|
|
112
|
-
"greeting.
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
46
|
+
return status, nil
|
|
132
47
|
}
|
|
133
48
|
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
for _,
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
t.Fatalf("media: %v", media)
|
|
118
|
+
if code, _ := call(t, cfg, alice, "sharedfolder.status", `{}`); code != "" {
|
|
119
|
+
t.Fatal(code)
|
|
127
120
|
}
|
|
128
|
-
if
|
|
129
|
-
t.Fatalf("
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
cfg
|
|
138
|
-
cfg
|
|
139
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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:'
|
|
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:'
|
|
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:'
|
|
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
|
|
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
|
-
|
|
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.
|
|
11
|
-
"@synopackageland/ui": "0.12.
|
|
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.
|
|
1156
|
-
"resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.
|
|
1157
|
-
"integrity": "sha512-
|
|
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.
|
|
1175
|
-
"resolved": "https://registry.npmjs.org/@synopackageland/ui/-/ui-0.12.
|
|
1176
|
-
"integrity": "sha512-
|
|
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",
|
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
})
|
|
@@ -104,8 +104,8 @@ dependencies = [
|
|
|
104
104
|
|
|
105
105
|
[[package]]
|
|
106
106
|
name = "syno-pkg-land-app"
|
|
107
|
-
version = "0.
|
|
108
|
-
source = "git+https://github.com/synopackageland/syno-pkg-land-sdk?tag=v0.
|
|
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.
|
|
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.
|