@synopackageland/cli 0.7.0 → 0.7.2
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/CHANGELOG.md +301 -1
- package/assets/default-icon/README.md +19 -0
- package/assets/default-icon/default-icon-small.svg +9 -0
- package/assets/default-icon/default-icon.svg +10 -0
- package/assets/default-icon/icon_16.png +0 -0
- package/assets/default-icon/icon_24.png +0 -0
- package/assets/default-icon/icon_256.png +0 -0
- package/assets/default-icon/icon_32.png +0 -0
- package/assets/default-icon/icon_48.png +0 -0
- package/assets/default-icon/icon_64.png +0 -0
- package/assets/default-icon/icon_72.png +0 -0
- package/dist/add.d.ts +4 -1
- package/dist/add.js +14 -9
- package/dist/build-spk/dsm-icons.d.ts +31 -2
- package/dist/build-spk/dsm-icons.js +135 -56
- package/dist/build-spk/dsm-platforms.json +4 -1
- package/dist/build-spk/gateway.d.ts +1 -1
- package/dist/build-spk/gateway.js +8 -4
- package/dist/build-spk/generator.js +17 -3
- package/dist/build-spk/go-toolchain.d.ts +20 -0
- package/dist/build-spk/go-toolchain.js +44 -0
- package/dist/build-spk/png.d.ts +14 -0
- package/dist/build-spk/png.js +161 -0
- package/dist/build-spk/ui-texts.d.ts +20 -0
- package/dist/build-spk/ui-texts.js +42 -0
- package/dist/cli.js +39 -9
- package/dist/contract/i18n.d.ts +5 -0
- package/dist/contract/i18n.js +45 -0
- package/dist/contract/icon.d.ts +5 -0
- package/dist/contract/icon.js +27 -0
- package/dist/contract/validate.js +4 -0
- package/dist/contract/web-launch.js +6 -2
- package/dist/deploy/native-install-values.d.ts +17 -0
- package/dist/deploy/native-install-values.js +32 -0
- package/dist/deploy/plan.d.ts +6 -2
- package/dist/deploy/plan.js +57 -6
- package/dist/error-registry.js +15 -6
- package/dist/host/dsm-client.js +53 -17
- package/dist/host/dsm-http.js +24 -2
- package/dist/host/types.d.ts +1 -0
- package/dist/info.js +28 -9
- package/dist/init.js +1 -1
- package/dist/runtime/dsm-version.d.ts +6 -0
- package/dist/runtime/dsm-version.js +8 -0
- package/dist/runtime/executor-build.d.ts +1 -1
- package/dist/runtime/executor-build.js +8 -5
- package/dist/standalone/daemon.d.ts +2 -1
- package/dist/standalone/daemon.js +10 -6
- package/dist/standalone/dsm-window.d.ts +2 -0
- package/dist/standalone/dsm-window.js +26 -40
- package/dist/standalone/i18n.d.ts +12 -0
- package/dist/standalone/i18n.js +30 -0
- package/dist/standalone/inspect-spk.d.ts +5 -0
- package/dist/standalone/inspect-spk.js +41 -2
- package/dist/standalone/manifest.d.ts +5 -6
- package/dist/standalone/manifest.js +21 -34
- package/dist/standalone/native-authorization-window.d.ts +1 -1
- package/dist/standalone/native-authorization-window.js +7 -1
- package/dist/standalone/native-layout.d.ts +2 -0
- package/dist/standalone/native-layout.js +22 -1
- package/dist/standalone/native-root-window.d.ts +4 -2
- package/dist/standalone/native-root-window.js +33 -18
- package/dist/standalone/root-window.d.ts +4 -1
- package/dist/standalone/root-window.js +143 -33
- package/dist/standalone/spk.js +7 -4
- package/dist/templates/_daemon/go.mod +1 -1
- package/dist/templates/_daemon/main.go +6 -3
- package/dist/templates/_daemon/main_test.go +21 -1
- package/dist/templates/_daemon/testdata/child.go +3 -0
- package/dist/templates/_executor/README.md +4 -1
- package/dist/templates/_executor/anchor.go +10 -0
- package/dist/templates/_executor/anchor_test.go +40 -5
- package/dist/templates/_executor/digest_review_test.go +2 -2
- package/dist/templates/_executor/dsm.go +40 -3
- package/dist/templates/_executor/go.mod +2 -0
- package/dist/templates/_executor/main.go +11 -5
- package/dist/templates/_executor/managed.go +3 -3
- package/dist/templates/_executor/managed_test.go +3 -3
- package/dist/templates/_executor/path_test.go +18 -0
- package/dist/templates/_executor/pid.go +37 -0
- package/dist/templates/_executor/pid_test.go +113 -0
- package/dist/templates/_executor/platform_other.go +8 -0
- package/dist/templates/_executor/purge_log.go +18 -0
- package/dist/templates/_executor/tasks.go +45 -7
- package/dist/templates/_executor/tasks_dsm71_test.go +114 -0
- package/dist/templates/_executor/tasks_test.go +58 -1
- package/dist/templates/_executor/upgrade_watch_test.go +13 -5
- package/dist/templates/_gateway/go.mod +2 -0
- package/dist/templates/binary/dsm-package.yaml +3 -0
- package/dist/templates/compose/synology-app.yaml +3 -0
- package/dist/templates/native-go/backend/go.mod +1 -1
- package/dist/templates/native-go/backend/internal/filestation/client.go +39 -3
- package/dist/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
- package/dist/templates/native-go/backend/internal/identity/README.md +5 -1
- package/dist/templates/native-go/backend/internal/identity/dsm.go +24 -5
- package/dist/templates/native-go/backend/internal/identity/http.go +1 -1
- package/dist/templates/native-go/backend/internal/identity/identity_test.go +48 -1
- package/dist/templates/native-go/backend/internal/identity/service.go +1 -1
- package/dist/templates/native-go/dsm-package.yaml +3 -0
- package/dist/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
- package/dist/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
- package/dist/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
- package/dist/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
- package/package.json +2 -1
- package/schema/synology-app.schema.json +22 -0
- package/templates/_daemon/go.mod +1 -1
- package/templates/_daemon/main.go +6 -3
- package/templates/_daemon/main_test.go +21 -1
- package/templates/_daemon/testdata/child.go +3 -0
- package/templates/_executor/README.md +4 -1
- package/templates/_executor/anchor.go +10 -0
- package/templates/_executor/anchor_test.go +40 -5
- package/templates/_executor/digest_review_test.go +2 -2
- package/templates/_executor/dsm.go +40 -3
- package/templates/_executor/go.mod +2 -0
- package/templates/_executor/main.go +11 -5
- package/templates/_executor/managed.go +3 -3
- package/templates/_executor/managed_test.go +3 -3
- package/templates/_executor/path_test.go +18 -0
- package/templates/_executor/pid.go +37 -0
- package/templates/_executor/pid_test.go +113 -0
- package/templates/_executor/platform_other.go +8 -0
- package/templates/_executor/purge_log.go +18 -0
- package/templates/_executor/tasks.go +45 -7
- package/templates/_executor/tasks_dsm71_test.go +114 -0
- package/templates/_executor/tasks_test.go +58 -1
- package/templates/_executor/upgrade_watch_test.go +13 -5
- package/templates/_gateway/go.mod +2 -0
- package/templates/binary/dsm-package.yaml +3 -0
- package/templates/compose/synology-app.yaml +3 -0
- package/templates/native-go/backend/go.mod +1 -1
- package/templates/native-go/backend/internal/filestation/client.go +39 -3
- package/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
- package/templates/native-go/backend/internal/identity/README.md +5 -1
- package/templates/native-go/backend/internal/identity/dsm.go +24 -5
- package/templates/native-go/backend/internal/identity/http.go +1 -1
- package/templates/native-go/backend/internal/identity/identity_test.go +48 -1
- package/templates/native-go/backend/internal/identity/service.go +1 -1
- package/templates/native-go/dsm-package.yaml +3 -0
- package/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
- package/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
- package/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
- package/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
- package/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
- package/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
- package/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
|
@@ -3,6 +3,9 @@ package: com.synology.hello.files
|
|
|
3
3
|
version: 0.1.0
|
|
4
4
|
displayname: Hello Files
|
|
5
5
|
description: A minimal headless app with a Shared Folder mount.
|
|
6
|
+
# Package Center and DSM window icon: a square PNG of at least 256x256, or a
|
|
7
|
+
# folder of icon_<size>.png. Omit to use the SDK default icon.
|
|
8
|
+
# icon: assets/icon.png
|
|
6
9
|
maintainer: Syno Package Land
|
|
7
10
|
os_min_ver: "7.2.1-69057"
|
|
8
11
|
|
|
@@ -21,6 +21,9 @@ const MaxBytes = 512 * 1024
|
|
|
21
21
|
const maxMetadata = 1024 * 1024
|
|
22
22
|
const PageSize = 100
|
|
23
23
|
|
|
24
|
+
// downloadPeek bounds how much of a DSM 7.1 download is inspected for an error envelope.
|
|
25
|
+
const downloadPeek = 4096
|
|
26
|
+
|
|
24
27
|
// Client has no cookie jar or credentials. The destination is startup-owned,
|
|
25
28
|
// literal loopback only; proxies, redirects and automatic retries are disabled.
|
|
26
29
|
type Client struct {
|
|
@@ -269,15 +272,48 @@ func (c *Client) DownloadStream(ctx context.Context, r *http.Request, p string)
|
|
|
269
272
|
if e != nil {
|
|
270
273
|
return Download{}, e
|
|
271
274
|
}
|
|
272
|
-
|
|
273
|
-
|
|
275
|
+
body := resp.Body
|
|
276
|
+
raw := resp.Header.Values("Content-Disposition")
|
|
277
|
+
if len(raw) == 0 {
|
|
278
|
+
// DSM 7.1 sends no Content-Disposition at all. Only a short body that is
|
|
279
|
+
// a complete DSM envelope is an error; everything else is the file.
|
|
280
|
+
if body, e = unlessEnvelope(resp); e != nil {
|
|
281
|
+
return Download{}, e
|
|
282
|
+
}
|
|
283
|
+
} else if disposition, _, _ := mime.ParseMediaType(raw[0]); len(raw) != 1 || disposition != "attachment" {
|
|
274
284
|
e = decode(resp, nil)
|
|
275
285
|
if e == nil {
|
|
276
286
|
e = fail("unavailable")
|
|
277
287
|
}
|
|
278
288
|
return Download{}, e
|
|
279
289
|
}
|
|
280
|
-
return Download{Body:
|
|
290
|
+
return Download{Body: body, ContentType: resp.Header.Get("Content-Type"), ContentDisposition: resp.Header.Get("Content-Disposition"), ContentLength: resp.ContentLength}, nil
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// unlessEnvelope returns the whole body as a stream unless it fits in the peek
|
|
294
|
+
// and decodes as a DSM envelope, which is refused and never becomes content.
|
|
295
|
+
func unlessEnvelope(resp *http.Response) (io.ReadCloser, error) {
|
|
296
|
+
head, e := io.ReadAll(io.LimitReader(resp.Body, downloadPeek+1))
|
|
297
|
+
if e != nil {
|
|
298
|
+
resp.Body.Close()
|
|
299
|
+
return nil, fail("read_failed")
|
|
300
|
+
}
|
|
301
|
+
if len(head) <= downloadPeek {
|
|
302
|
+
var envelope struct {
|
|
303
|
+
Success *bool `json:"success"`
|
|
304
|
+
}
|
|
305
|
+
if json.Unmarshal(head, &envelope) == nil && envelope.Success != nil {
|
|
306
|
+
resp.Body = io.NopCloser(bytes.NewReader(head))
|
|
307
|
+
if e = decode(resp, nil); e == nil {
|
|
308
|
+
e = fail("unavailable")
|
|
309
|
+
}
|
|
310
|
+
return nil, e
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return struct {
|
|
314
|
+
io.Reader
|
|
315
|
+
io.Closer
|
|
316
|
+
}{io.MultiReader(bytes.NewReader(head), resp.Body), resp.Body}, nil
|
|
281
317
|
}
|
|
282
318
|
|
|
283
319
|
// Read is the bounded package-private job import, using the same stream.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
package filestation
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"net/http"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// DSM 7.1.1-42962 (vt-62, t-0231) answers Download v2 mode=download with the
|
|
13
|
+
// file bytes and no Content-Disposition header. Its Content-Type was not
|
|
14
|
+
// recorded, so every plausible type is covered.
|
|
15
|
+
func dsm71Download(contentType, body string) http.HandlerFunc {
|
|
16
|
+
return func(w http.ResponseWriter, r *http.Request) {
|
|
17
|
+
if contentType != "" {
|
|
18
|
+
w.Header().Set("Content-Type", contentType)
|
|
19
|
+
}
|
|
20
|
+
fmt.Fprint(w, body)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func TestDSM71DownloadWithoutDispositionReturnsFileBytes(t *testing.T) {
|
|
25
|
+
for _, tt := range []struct{ contentType, body string }{
|
|
26
|
+
{"text/plain", "t0231 fixture a.txt"},
|
|
27
|
+
{"application/octet-stream", "\x00\x01binary"},
|
|
28
|
+
{"", "no type"},
|
|
29
|
+
{"application/json", `{"name":"a user file"}`},
|
|
30
|
+
{"application/json", `[{"success":false}]`},
|
|
31
|
+
{"text/plain", strings.Repeat("y", downloadPeek+7)},
|
|
32
|
+
} {
|
|
33
|
+
t.Run(tt.contentType, func(t *testing.T) {
|
|
34
|
+
c := fakeClient(t, dsm71Download(tt.contentType, tt.body))
|
|
35
|
+
b, err := c.Read(context.Background(), sessionRequest(), "/home/a")
|
|
36
|
+
if err != nil || string(b) != tt.body {
|
|
37
|
+
t.Fatalf("%v %q", err, b)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func TestDSM71DownloadStreamsBeyondThePeek(t *testing.T) {
|
|
44
|
+
body := strings.Repeat("z", MaxBytes*3)
|
|
45
|
+
c := fakeClient(t, dsm71Download("application/octet-stream", body))
|
|
46
|
+
d, err := c.DownloadStream(context.Background(), sessionRequest(), "/home/a")
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
defer d.Body.Close()
|
|
51
|
+
b, err := io.ReadAll(d.Body)
|
|
52
|
+
if err != nil || string(b) != body || d.ContentType != "application/octet-stream" {
|
|
53
|
+
t.Fatal(err, len(b))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func TestDSM71DownloadEnvelopeWithoutDispositionIsNeverContent(t *testing.T) {
|
|
58
|
+
for _, tt := range []struct{ contentType, body, want string }{
|
|
59
|
+
{"application/json; charset=utf-8", `{"success":false,"error":{"code":408}}`, "denied"},
|
|
60
|
+
{"text/plain", `{"success":false,"error":{"code":119}}`, "session_required"},
|
|
61
|
+
{"", ` {"error":{"code":407},"success":false}` + "\n", "denied"},
|
|
62
|
+
{"application/json", `{"success":true,"data":{}}`, "unavailable"},
|
|
63
|
+
} {
|
|
64
|
+
t.Run(tt.want, func(t *testing.T) {
|
|
65
|
+
c := fakeClient(t, dsm71Download(tt.contentType, tt.body))
|
|
66
|
+
b, err := c.Read(context.Background(), sessionRequest(), "/home/a")
|
|
67
|
+
if err == nil || len(b) != 0 || !strings.Contains(err.Error(), tt.want) {
|
|
68
|
+
t.Fatalf("%v %q", err, b)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func TestNonAttachmentDispositionStaysRefused(t *testing.T) {
|
|
75
|
+
c := fakeClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
76
|
+
w.Header().Set("Content-Disposition", `inline; filename="a"`)
|
|
77
|
+
fmt.Fprint(w, "abc")
|
|
78
|
+
})
|
|
79
|
+
if b, err := c.Read(context.Background(), sessionRequest(), "/home/a"); err == nil || len(b) != 0 {
|
|
80
|
+
t.Fatal("inline disposition admitted")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -16,7 +16,11 @@ The transport accepts same-origin JSON POST requests:
|
|
|
16
16
|
- `identity.logout {}` removes the app session and clears the cookie.
|
|
17
17
|
|
|
18
18
|
NormalUser.get is the only DSM cookie identity check; anonymous Initdata success
|
|
19
|
-
cannot establish identity.
|
|
19
|
+
cannot establish identity. DSM web logins enable SynoToken, and DSM refuses
|
|
20
|
+
their cookie alone (119), so the check also forwards the request's single
|
|
21
|
+
`X-SYNO-TOKEN` header, which the DSM window's `ctx.call` sends. Sessions without
|
|
22
|
+
SynoToken still verify with the cookie alone. The token is never put in a URL,
|
|
23
|
+
logged or stored. Local `user.Lookup` binds the username to its UID.
|
|
20
24
|
Auth login creates a temporary SID, then immediately logs it out before issuing
|
|
21
25
|
an app session. A logout failure refuses the app login. No password, OTP, DSM SID
|
|
22
26
|
or DSM cookie is logged or persisted. Only hashes of app bearer IDs are stored.
|
|
@@ -9,6 +9,7 @@ import (
|
|
|
9
9
|
"net/url"
|
|
10
10
|
"strings"
|
|
11
11
|
"time"
|
|
12
|
+
"unicode"
|
|
12
13
|
)
|
|
13
14
|
|
|
14
15
|
type dsmReply struct {
|
|
@@ -25,7 +26,9 @@ type dsmReply struct {
|
|
|
25
26
|
} `json:"error"`
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
// dsmCall sends token only as X-SYNO-TOKEN: DSM web logins enable SynoToken,
|
|
30
|
+
// and their cookie alone is refused with 119.
|
|
31
|
+
func (s *Service) dsmCall(ctx context.Context, form url.Values, cookie, token string) (dsmReply, error) {
|
|
29
32
|
var out dsmReply
|
|
30
33
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
31
34
|
defer cancel()
|
|
@@ -37,6 +40,9 @@ func (s *Service) dsmCall(ctx context.Context, form url.Values, cookie string) (
|
|
|
37
40
|
if cookie != "" {
|
|
38
41
|
req.AddCookie(&http.Cookie{Name: "id", Value: cookie})
|
|
39
42
|
}
|
|
43
|
+
if token != "" {
|
|
44
|
+
req.Header.Set("X-SYNO-TOKEN", token)
|
|
45
|
+
}
|
|
40
46
|
res, err := s.cfg.Client.Do(req)
|
|
41
47
|
if err != nil {
|
|
42
48
|
return out, errors.New("DSM unavailable")
|
|
@@ -48,19 +54,32 @@ func (s *Service) dsmCall(ctx context.Context, form url.Values, cookie string) (
|
|
|
48
54
|
}
|
|
49
55
|
return out, nil
|
|
50
56
|
}
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
|
|
58
|
+
// verify checks the caller's DSM session with the request's cookie and, when the
|
|
59
|
+
// browser sent one, its SynoToken. Neither is stored or logged.
|
|
60
|
+
func (s *Service) verify(r *http.Request, cookie string) (Subject, error) {
|
|
61
|
+
token := synoToken(r)
|
|
62
|
+
out, err := s.dsmCall(r.Context(), url.Values{"api": {"SYNO.Core.NormalUser"}, "method": {"get"}, "version": {"1"}}, cookie, token)
|
|
53
63
|
if err != nil || !out.Success {
|
|
54
64
|
return Subject{}, errors.New("DSM session refused")
|
|
55
65
|
}
|
|
56
66
|
return s.resolve(out.Data.Username)
|
|
57
67
|
}
|
|
68
|
+
|
|
69
|
+
// synoToken returns the single, well-formed X-SYNO-TOKEN header or "".
|
|
70
|
+
func synoToken(r *http.Request) string {
|
|
71
|
+
values := r.Header.Values("X-SYNO-TOKEN")
|
|
72
|
+
if len(values) != 1 || len(values[0]) > 4096 || strings.ContainsFunc(values[0], unicode.IsControl) {
|
|
73
|
+
return ""
|
|
74
|
+
}
|
|
75
|
+
return values[0]
|
|
76
|
+
}
|
|
58
77
|
func (s *Service) login(ctx context.Context, account, password, otp string) (Subject, string) {
|
|
59
78
|
form := url.Values{"api": {"SYNO.API.Auth"}, "method": {"login"}, "version": {"7"}, "account": {account}, "passwd": {password}, "format": {"sid"}}
|
|
60
79
|
if otp != "" {
|
|
61
80
|
form.Set("otp_code", otp)
|
|
62
81
|
}
|
|
63
|
-
out, err := s.dsmCall(ctx, form, "")
|
|
82
|
+
out, err := s.dsmCall(ctx, form, "", "")
|
|
64
83
|
if err != nil {
|
|
65
84
|
return Subject{}, "auth_unavailable"
|
|
66
85
|
}
|
|
@@ -84,7 +103,7 @@ func (s *Service) login(ctx context.Context, account, password, otp string) (Sub
|
|
|
84
103
|
}
|
|
85
104
|
// The temporary DSM session exists only to prove identity. Dispose of it
|
|
86
105
|
// before issuing an application session, including when local lookup fails.
|
|
87
|
-
loggedOut, err := s.dsmCall(context.WithoutCancel(ctx), url.Values{"api": {"SYNO.API.Auth"}, "method": {"logout"}, "version": {"7"}, "_sid": {out.Data.SID}}, "")
|
|
106
|
+
loggedOut, err := s.dsmCall(context.WithoutCancel(ctx), url.Values{"api": {"SYNO.API.Auth"}, "method": {"logout"}, "version": {"7"}, "_sid": {out.Data.SID}}, "", "")
|
|
88
107
|
if err != nil || !loggedOut.Success {
|
|
89
108
|
return Subject{}, "auth_unavailable"
|
|
90
109
|
}
|
|
@@ -237,7 +237,7 @@ func (s *Service) handle(w http.ResponseWriter, r *http.Request, req request) {
|
|
|
237
237
|
code = "session_required"
|
|
238
238
|
return nil
|
|
239
239
|
}
|
|
240
|
-
sub, err := s.verify(r
|
|
240
|
+
sub, err := s.verify(r, dc.Value)
|
|
241
241
|
if err != nil {
|
|
242
242
|
code = "session_required"
|
|
243
243
|
return nil
|
|
@@ -27,6 +27,11 @@ type fixture struct {
|
|
|
27
27
|
logout int
|
|
28
28
|
calls []string
|
|
29
29
|
dir string
|
|
30
|
+
// synoToken makes the fake DSM session a SynoToken session: NormalUser.get
|
|
31
|
+
// then needs the cookie and this X-SYNO-TOKEN header, as DSM web logins do.
|
|
32
|
+
synoToken string
|
|
33
|
+
// token is the X-SYNO-TOKEN the browser sends with each App request.
|
|
34
|
+
token string
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
func setup(t *testing.T) *fixture {
|
|
@@ -40,7 +45,10 @@ func setup(t *testing.T) *fixture {
|
|
|
40
45
|
switch name {
|
|
41
46
|
case "SYNO.Core.NormalUser.get":
|
|
42
47
|
c, err := r.Cookie("id")
|
|
43
|
-
if
|
|
48
|
+
if r.Form.Has("SynoToken") {
|
|
49
|
+
t.Error("SynoToken must travel only in the header")
|
|
50
|
+
}
|
|
51
|
+
if f.valid && err == nil && c.Value == "valid-dsm-cookie" && (f.synoToken == "" || r.Header.Get("X-SYNO-TOKEN") == f.synoToken) {
|
|
44
52
|
fmt.Fprint(w, `{"success":true,"data":{"username":"alice"}}`)
|
|
45
53
|
} else {
|
|
46
54
|
fmt.Fprint(w, `{"success":false,"error":{"code":119}}`)
|
|
@@ -93,6 +101,9 @@ func (f *fixture) call(method, params string, appCookie *http.Cookie, dsmCookie
|
|
|
93
101
|
if dsmCookie {
|
|
94
102
|
req.AddCookie(&http.Cookie{Name: "id", Value: "valid-dsm-cookie"})
|
|
95
103
|
}
|
|
104
|
+
if f.token != "" {
|
|
105
|
+
req.Header.Set("X-SYNO-TOKEN", f.token)
|
|
106
|
+
}
|
|
96
107
|
out := httptest.NewRecorder()
|
|
97
108
|
f.service.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
98
109
|
id, err := f.service.Authenticate(r)
|
|
@@ -148,6 +159,42 @@ func TestCookieExchangeAndNoInitdataShortcut(t *testing.T) {
|
|
|
148
159
|
}
|
|
149
160
|
}
|
|
150
161
|
}
|
|
162
|
+
func TestSynoTokenSessionBootstrapsAndRevalidates(t *testing.T) {
|
|
163
|
+
f := setup(t)
|
|
164
|
+
f.synoToken = "secret-syno-token"
|
|
165
|
+
if _, out := f.call("identity.session", `{"bootstrap":true}`, nil, true); errorCode(out) != "session_required" {
|
|
166
|
+
t.Fatalf("cookie alone must not pass a SynoToken session: %v", out)
|
|
167
|
+
}
|
|
168
|
+
f.token = "secret-syno-token"
|
|
169
|
+
rec, out := f.call("identity.session", `{"bootstrap":true}`, nil, true)
|
|
170
|
+
if errorCode(out) != "" {
|
|
171
|
+
t.Fatalf("bootstrap with SynoToken: %v", out)
|
|
172
|
+
}
|
|
173
|
+
c := sessionCookie(t, rec)
|
|
174
|
+
if _, out = f.call("greeting.set", `{"text":"x"}`, c, true); errorCode(out) != "" {
|
|
175
|
+
t.Fatalf("live revalidation with SynoToken: %v", out)
|
|
176
|
+
}
|
|
177
|
+
filepath.WalkDir(f.dir, func(path string, d os.DirEntry, err error) error {
|
|
178
|
+
if err != nil {
|
|
179
|
+
t.Fatal(err)
|
|
180
|
+
}
|
|
181
|
+
if raw, _ := os.ReadFile(path); !d.IsDir() && strings.Contains(string(raw), "secret-syno-token") {
|
|
182
|
+
t.Fatalf("SynoToken written to %s", path)
|
|
183
|
+
}
|
|
184
|
+
return nil
|
|
185
|
+
})
|
|
186
|
+
f.token = ""
|
|
187
|
+
if _, out = f.call("greeting.set", `{"text":"x"}`, c, true); errorCode(out) != "session_required" {
|
|
188
|
+
t.Fatalf("revalidation without SynoToken: %v", out)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
func TestTokenlessSessionStillVerifiesWhenBrowserSendsToken(t *testing.T) {
|
|
192
|
+
f := setup(t)
|
|
193
|
+
f.token = "unused-token"
|
|
194
|
+
if _, out := f.call("identity.session", `{"bootstrap":true}`, nil, true); errorCode(out) != "" {
|
|
195
|
+
t.Fatal(out)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
151
198
|
func TestLoginOTPLogoutAndNoCredentialsOnDisk(t *testing.T) {
|
|
152
199
|
f := setup(t)
|
|
153
200
|
f.otp = true
|
|
@@ -145,7 +145,7 @@ func (s *Service) current(st *state, r *http.Request, revalidate bool) (string,
|
|
|
145
145
|
if err != nil || dc.Value == "" {
|
|
146
146
|
return "", record{}, errRevalidation
|
|
147
147
|
}
|
|
148
|
-
live, err := s.verify(r
|
|
148
|
+
live, err := s.verify(r, dc.Value)
|
|
149
149
|
if err != nil || live != sub {
|
|
150
150
|
delete(st.Sessions, key)
|
|
151
151
|
return "", record{}, api.ErrNoSession
|
|
@@ -10,6 +10,9 @@ version: 0.1.0
|
|
|
10
10
|
build: 1
|
|
11
11
|
displayname: __DISPLAY_NAME__
|
|
12
12
|
description: A native DSM package with a Go backend and a DSM window UI.
|
|
13
|
+
# Package Center and DSM window icon: a square PNG of at least 256x256, or a
|
|
14
|
+
# folder of icon_<size>.png. Omit to use the SDK default icon.
|
|
15
|
+
# icon: assets/icon.png
|
|
13
16
|
maintainer: Your Name
|
|
14
17
|
# Optional INFO/website metadata. Omit fields you do not need.
|
|
15
18
|
# metadata:
|
|
@@ -128,3 +128,18 @@ test('startup waiting catalog covers exactly nine distinct language messages',()
|
|
|
128
128
|
expect(Object.keys(waitingMessages).sort()).toEqual(['en','zh-TW','zh-CN','de','es','fr','it','ja','ko'].sort())
|
|
129
129
|
expect(new Set(Object.values(waitingMessages)).size).toBe(9)
|
|
130
130
|
})
|
|
131
|
+
test('a blocked approval shows the DSM reason, not only the generic unavailable text',async()=>{
|
|
132
|
+
const setup:ExecutorSetup={control:vi.fn(async()=>({state:'ready'})),status:vi.fn(async()=>({state:'needs-admin-approval'})),authorize:vi.fn(async()=>({state:'blocked',reason:'dsm_104: scheduler readback or operation failed.'}))}
|
|
133
|
+
const root=mount(ExecutorSettings,{admin:true},setup);await settle();const password=root.querySelector('input[type="password"]') as HTMLInputElement;password.value='once';password.dispatchEvent(new Event('input',{bubbles:true}));await settle();root.querySelector('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));await settle()
|
|
134
|
+
expect(root.textContent).toContain('Background file access is unavailable.');expect(root.textContent).toContain('Details: dsm_104: scheduler readback or operation failed.')
|
|
135
|
+
})
|
|
136
|
+
test('a task that is only missing shows no extra details',async()=>{
|
|
137
|
+
const setup:ExecutorSetup={control:vi.fn(async()=>({state:'ready'})),status:vi.fn(async()=>({state:'needs-admin-approval',reason:'Task is missing.'})),authorize:vi.fn(async()=>({state:'ready'}))}
|
|
138
|
+
const root=mount(ExecutorSettings,{admin:true},setup);await settle();expect(root.textContent).not.toContain('Task is missing.')
|
|
139
|
+
})
|
|
140
|
+
test('settings follow an approval made from the window panel',async()=>{
|
|
141
|
+
let notify:((r:{state:'ready'})=>void)|undefined
|
|
142
|
+
const setup={control:vi.fn(async()=>({state:'ready'})),status:vi.fn(async()=>({state:'needs-admin-approval'})),authorize:vi.fn(async()=>({state:'ready'})),subscribe:(listener:any)=>{notify=listener}} as ExecutorSetup
|
|
143
|
+
const root=mount(ExecutorSettings,{admin:true},setup);await settle();expect(root.textContent).toContain('An administrator must approve')
|
|
144
|
+
notify!({state:'ready'});await settle();expect(root.textContent).toContain('Background file access is ready.')
|
|
145
|
+
})
|
|
@@ -9,6 +9,8 @@ import (
|
|
|
9
9
|
"net/http"
|
|
10
10
|
"os"
|
|
11
11
|
"path/filepath"
|
|
12
|
+
"strings"
|
|
13
|
+
"syscall"
|
|
12
14
|
"time"
|
|
13
15
|
|
|
14
16
|
"dsmapp/internal/api"
|
|
@@ -51,8 +53,31 @@ func (cfg Config) executorControl(_ context.Context, caller api.Caller, raw json
|
|
|
51
53
|
return map[string]bool{"written": true}, nil
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
// The executor closes its socket while paused: the package is stopped, or an
|
|
57
|
+
// upgrade changed the pins and awaits administrator approval.
|
|
54
58
|
func (cfg Config) executorStatus(ctx context.Context, _ api.Caller, _ json.RawMessage) (any, error) {
|
|
55
|
-
|
|
59
|
+
status, err := cfg.executorRequest(ctx, "status", nil, nil)
|
|
60
|
+
if errors.Is(err, syscall.ENOENT) || errors.Is(err, syscall.ECONNREFUSED) {
|
|
61
|
+
next := "Start the package."
|
|
62
|
+
if cfg.startRequested() {
|
|
63
|
+
next = "The package is running: an administrator approves background access again in Administrator setup; the executor log names the cause."
|
|
64
|
+
}
|
|
65
|
+
return map[string]any{"ready": false, "state": "paused", "reason": "executor_not_listening", "next": next}, nil
|
|
66
|
+
}
|
|
67
|
+
return status, err
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Package start and stop write the lifecycle mailbox; its last request tells a
|
|
71
|
+
// stopped package from a running one whose executor stays paused.
|
|
72
|
+
func (cfg Config) startRequested() bool {
|
|
73
|
+
f, err := os.Open(filepath.Join(cfg.DataDir, "executor", "control"))
|
|
74
|
+
if err != nil {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
defer f.Close()
|
|
78
|
+
b := make([]byte, 16)
|
|
79
|
+
n, _ := f.Read(b)
|
|
80
|
+
return strings.TrimSpace(string(b[:n])) == "start"
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
func (cfg Config) executorRootRun(ctx context.Context, caller api.Caller, req *http.Request, raw json.RawMessage) (any, error) {
|
|
@@ -73,8 +98,11 @@ func (cfg Config) executorRootRun(ctx context.Context, caller api.Caller, req *h
|
|
|
73
98
|
return cfg.executorRequest(ctx, "root.run", auth, map[string]string{"name": in.Name})
|
|
74
99
|
}
|
|
75
100
|
|
|
101
|
+
// executorSocketBase is a variable so tests can use a temporary directory.
|
|
102
|
+
var executorSocketBase = "/run/synopkgland"
|
|
103
|
+
|
|
76
104
|
func (cfg Config) executorRequest(ctx context.Context, op string, auth, params any) (any, error) {
|
|
77
|
-
conn, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "unix",
|
|
105
|
+
conn, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "unix", filepath.Join(executorSocketBase, cfg.PackageID, "executor.sock"))
|
|
78
106
|
if err != nil {
|
|
79
107
|
return nil, err
|
|
80
108
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
package app
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"net"
|
|
4
5
|
"os"
|
|
5
6
|
"path/filepath"
|
|
7
|
+
"strings"
|
|
6
8
|
"testing"
|
|
7
9
|
)
|
|
8
10
|
|
|
@@ -51,3 +53,65 @@ func TestExecutorRootRunRequiresCurrentDSMCookie(t *testing.T) {
|
|
|
51
53
|
t.Fatal("root.run accepted absent DSM cookie")
|
|
52
54
|
}
|
|
53
55
|
}
|
|
56
|
+
|
|
57
|
+
// The executor closes its listener while paused (package stopped, or pins
|
|
58
|
+
// awaiting administrator approval). Status then answers, not "internal".
|
|
59
|
+
func TestExecutorStatusReportsAPausedExecutor(t *testing.T) {
|
|
60
|
+
cfg := testConfig(t)
|
|
61
|
+
previous := executorSocketBase
|
|
62
|
+
// A short base: unix socket paths are limited to about 100 bytes.
|
|
63
|
+
base, err := os.MkdirTemp("/tmp", "exec")
|
|
64
|
+
if err != nil {
|
|
65
|
+
t.Fatal(err)
|
|
66
|
+
}
|
|
67
|
+
executorSocketBase = base
|
|
68
|
+
defer func() { executorSocketBase = previous; os.RemoveAll(base) }()
|
|
69
|
+
check := func(name string) {
|
|
70
|
+
t.Helper()
|
|
71
|
+
code, envelope := call(t, cfg, alice, "executor.status", `{}`)
|
|
72
|
+
out, _ := envelope["data"].(map[string]any)
|
|
73
|
+
if code != "" || out["ready"] != false || out["state"] != "paused" || out["reason"] != "executor_not_listening" || out["next"] == "" || out["next"] == nil {
|
|
74
|
+
t.Fatalf("%s: %s %v", name, code, out)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
check("no socket")
|
|
78
|
+
if code, _ := call(t, cfg, bob, "executor.status", `{}`); code != "" {
|
|
79
|
+
t.Fatal("authenticated user cannot read a paused status", code)
|
|
80
|
+
}
|
|
81
|
+
dir := filepath.Join(executorSocketBase, cfg.PackageID)
|
|
82
|
+
os.MkdirAll(dir, 0700)
|
|
83
|
+
ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: filepath.Join(dir, "executor.sock"), Net: "unix"})
|
|
84
|
+
if err != nil {
|
|
85
|
+
t.Fatal(err)
|
|
86
|
+
}
|
|
87
|
+
ln.SetUnlinkOnClose(false)
|
|
88
|
+
ln.Close()
|
|
89
|
+
check("closed listener")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// next follows the last lifecycle request: a running package needs approval
|
|
93
|
+
// again, not a start.
|
|
94
|
+
func TestExecutorStatusNextFollowsTheLifecycleRequest(t *testing.T) {
|
|
95
|
+
cfg := testConfig(t)
|
|
96
|
+
previous := executorSocketBase
|
|
97
|
+
executorSocketBase = t.TempDir()
|
|
98
|
+
defer func() { executorSocketBase = previous }()
|
|
99
|
+
next := func() string {
|
|
100
|
+
t.Helper()
|
|
101
|
+
_, envelope := call(t, cfg, alice, "executor.status", `{}`)
|
|
102
|
+
out, _ := envelope["data"].(map[string]any)
|
|
103
|
+
s, _ := out["next"].(string)
|
|
104
|
+
return s
|
|
105
|
+
}
|
|
106
|
+
if !strings.HasPrefix(next(), "Start the package.") {
|
|
107
|
+
t.Fatal("no request:", next())
|
|
108
|
+
}
|
|
109
|
+
mailbox := filepath.Join(cfg.DataDir, "executor")
|
|
110
|
+
os.MkdirAll(mailbox, 0700)
|
|
111
|
+
for request, start := range map[string]bool{"stop\n": true, "start\n": false, "start": false} {
|
|
112
|
+
os.WriteFile(filepath.Join(mailbox, "control"), []byte(request), 0600)
|
|
113
|
+
if got := next(); strings.HasPrefix(got, "Start the package") != start || got == "" {
|
|
114
|
+
t.Fatalf("%q: %s", request, got)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -47,16 +47,17 @@ async function authorize(){
|
|
|
47
47
|
catch{if(!disposed)error.value=true}
|
|
48
48
|
finally{if(!disposed)busy.value=false}
|
|
49
49
|
}
|
|
50
|
-
onMounted(()=>void refresh());onBeforeUnmount(()=>{disposed=true;password.value=''})
|
|
50
|
+
onMounted(()=>{setup.subscribe?.(next=>{if(!disposed)status.value=next});void refresh()});onBeforeUnmount(()=>{disposed=true;password.value=''})
|
|
51
51
|
</script>
|
|
52
52
|
<template>
|
|
53
53
|
<SettingsSection v-if="admin" :title="copy.setupTitle" :collapsible="false" :aria-busy="busy">
|
|
54
54
|
<p>{{ copy.setupHelp }}</p>
|
|
55
55
|
<Callout v-if="error" tone="danger" role="alert">{{ copy.setupError }}</Callout>
|
|
56
56
|
<p role="status">{{ status?.state==='ready'?(needsScope?copy.scopeNeeds:copy.ready):status?.state==='needs-admin-approval'?copy.needs:copy.blocked }}</p>
|
|
57
|
+
<p v-if="status?.reason&&(status.state==='blocked'||status.state==='unsupported')">{{ copy.details }}: {{ status.reason }}</p>
|
|
57
58
|
<p v-if="scope?.selection">{{ copy.protectedScope }}: {{ scope.selection.root }}</p>
|
|
58
59
|
<Callout v-if="scope?.mismatch" tone="warning">{{ copy.scopeMismatch }} {{ copy.preselection }}: {{ scope.preselection }} · {{ copy.protectedScope }}: {{ scope.selection?.root }}</Callout>
|
|
59
|
-
<Form v-if="status?.state==='needs-admin-approval'||dynamic&&status?.state==='ready'" @submit.prevent="authorize">
|
|
60
|
+
<Form v-if="status?.state==='needs-admin-approval'||status?.retry||dynamic&&status?.state==='ready'" @submit.prevent="authorize">
|
|
60
61
|
<template v-if="dynamic">
|
|
61
62
|
<p>{{ scope?.selection?copy.scopeChangeHelp:copy.preselectionHelp }}</p>
|
|
62
63
|
<FormItem :label="copy.scopeLabel" label-for="delegate-scope"><Select id="delegate-scope" v-model="chosen" :options="options" :disabled="busy||error" /></FormItem>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import {expect,test} from 'vitest'
|
|
3
|
+
import {createApp,h,nextTick} from 'vue'
|
|
4
|
+
import {AppRoot} from '@synopackageland/ui'
|
|
5
|
+
import ExecutorSettings from './ExecutorSettings.vue'
|
|
6
|
+
import {ExecutorSetupKey,type CapabilityStatus} from './executor-setup'
|
|
7
|
+
const settle=async()=>{for(let i=0;i<10;i++){await nextTick();await new Promise(r=>setTimeout(r))}}
|
|
8
|
+
// t-0237: a wrong password left only "Check status"; a retryable result keeps the form.
|
|
9
|
+
test('a retryable blocked approval keeps the password form for another try',async()=>{
|
|
10
|
+
const blocked:CapabilityStatus&{retry:boolean}={state:'blocked',reason:'dsm_3121: DSM rejected the administrator password. Nothing was created.',retry:true}
|
|
11
|
+
const setup={async status():Promise<CapabilityStatus>{return {state:'needs-admin-approval'}},async authorize(){return blocked},async control(){return blocked}}
|
|
12
|
+
const root=document.createElement('div');document.body.append(root)
|
|
13
|
+
const app=createApp({render:()=>h(AppRoot,{},()=>h(ExecutorSettings,{admin:true,locale:'en'}))})
|
|
14
|
+
app.provide(ExecutorSetupKey,setup)
|
|
15
|
+
try{
|
|
16
|
+
app.mount(root);await settle()
|
|
17
|
+
const input=root.querySelector<HTMLInputElement>('#delegate-password')!
|
|
18
|
+
input.value='wrong';input.dispatchEvent(new Event('input'));await settle()
|
|
19
|
+
root.querySelector('form')!.dispatchEvent(new Event('submit'));await settle()
|
|
20
|
+
expect(root.textContent).toContain('dsm_3121')
|
|
21
|
+
expect(root.querySelector('#delegate-password')).not.toBeNull()
|
|
22
|
+
}finally{app.unmount();root.remove()}
|
|
23
|
+
})
|
|
@@ -2,13 +2,17 @@ import {inject,type InjectionKey} from 'vue'
|
|
|
2
2
|
export type CapabilityState='unsupported'|'needs-admin-approval'|'ready'|'blocked'|'incompatible'
|
|
3
3
|
// requested: a scope change whose root task may still land; it applied once
|
|
4
4
|
// status() reports that scope with a generation above this one.
|
|
5
|
-
|
|
5
|
+
// retry: approving again may succeed: nothing of this approval is left behind, or
|
|
6
|
+
// the approved tasks remain and approving again restarts the executor.
|
|
7
|
+
export interface CapabilityStatus {state:CapabilityState;reason?:string;requested?:{scope:string;generation:number};retry?:boolean}
|
|
6
8
|
// p2-setup supplies the implementation through app.provide(ExecutorSetupKey, setup).
|
|
7
9
|
// Passwords belong only to this call and must never be persisted or logged.
|
|
8
10
|
export interface ExecutorSetup {
|
|
9
11
|
status():Promise<CapabilityStatus>
|
|
10
12
|
authorize(password:string,selectedScope?:string):Promise<CapabilityStatus>
|
|
11
13
|
control(action:'start'|'stop'|'purge'):Promise<CapabilityStatus>
|
|
14
|
+
// Results of calls made elsewhere (the DSM window panel) on the same access.
|
|
15
|
+
subscribe?(listener:(status:CapabilityStatus)=>void):void
|
|
12
16
|
}
|
|
13
17
|
export const ExecutorSetupKey:InjectionKey<ExecutorSetup>=Symbol('executor-setup')
|
|
14
18
|
export function useExecutorSetup():ExecutorSetup {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"ready": "Der Dateizugriff im Hintergrund ist bereit.",
|
|
11
11
|
"needs": "Ein Administrator muss den Dateizugriff im Hintergrund genehmigen.",
|
|
12
12
|
"blocked": "Der Dateizugriff im Hintergrund ist nicht verfügbar. Bitten Sie einen Administrator, die App-Einrichtung zu prüfen.",
|
|
13
|
+
"details": "Details",
|
|
13
14
|
"refresh": "Aktualisieren",
|
|
14
15
|
"revoke": "Widerrufen",
|
|
15
16
|
"cancel": "Auftrag abbrechen",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"ready": "Background file access is ready.",
|
|
11
11
|
"needs": "An administrator must approve background file access.",
|
|
12
12
|
"blocked": "Background file access is unavailable. Ask an administrator to check the app setup.",
|
|
13
|
+
"details": "Details",
|
|
13
14
|
"refresh": "Refresh",
|
|
14
15
|
"revoke": "Revoke",
|
|
15
16
|
"cancel": "Cancel job",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"ready": "El acceso a archivos en segundo plano está listo.",
|
|
11
11
|
"needs": "Un administrador debe aprobar el acceso a archivos en segundo plano.",
|
|
12
12
|
"blocked": "El acceso a archivos en segundo plano no está disponible. Pida a un administrador que revise la configuración de la aplicación.",
|
|
13
|
+
"details": "Detalles",
|
|
13
14
|
"refresh": "Actualizar",
|
|
14
15
|
"revoke": "Revocar",
|
|
15
16
|
"cancel": "Cancelar tarea",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"ready": "L’accès aux fichiers en arrière-plan est prêt.",
|
|
11
11
|
"needs": "Un administrateur doit approuver l’accès aux fichiers en arrière-plan.",
|
|
12
12
|
"blocked": "L’accès aux fichiers en arrière-plan n’est pas disponible. Demandez à un administrateur de vérifier la configuration de l’application.",
|
|
13
|
+
"details": "Détails",
|
|
13
14
|
"refresh": "Actualiser",
|
|
14
15
|
"revoke": "Révoquer",
|
|
15
16
|
"cancel": "Annuler la tâche",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"ready": "L’accesso ai file in background è pronto.",
|
|
11
11
|
"needs": "Un amministratore deve approvare l’accesso ai file in background.",
|
|
12
12
|
"blocked": "L’accesso ai file in background non è disponibile. Chiedere a un amministratore di controllare la configurazione dell’app.",
|
|
13
|
+
"details": "Dettagli",
|
|
13
14
|
"refresh": "Aggiorna",
|
|
14
15
|
"revoke": "Revoca",
|
|
15
16
|
"cancel": "Annulla processo",
|