@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.
Files changed (165) hide show
  1. package/CHANGELOG.md +301 -1
  2. package/assets/default-icon/README.md +19 -0
  3. package/assets/default-icon/default-icon-small.svg +9 -0
  4. package/assets/default-icon/default-icon.svg +10 -0
  5. package/assets/default-icon/icon_16.png +0 -0
  6. package/assets/default-icon/icon_24.png +0 -0
  7. package/assets/default-icon/icon_256.png +0 -0
  8. package/assets/default-icon/icon_32.png +0 -0
  9. package/assets/default-icon/icon_48.png +0 -0
  10. package/assets/default-icon/icon_64.png +0 -0
  11. package/assets/default-icon/icon_72.png +0 -0
  12. package/dist/add.d.ts +4 -1
  13. package/dist/add.js +14 -9
  14. package/dist/build-spk/dsm-icons.d.ts +31 -2
  15. package/dist/build-spk/dsm-icons.js +135 -56
  16. package/dist/build-spk/dsm-platforms.json +4 -1
  17. package/dist/build-spk/gateway.d.ts +1 -1
  18. package/dist/build-spk/gateway.js +8 -4
  19. package/dist/build-spk/generator.js +17 -3
  20. package/dist/build-spk/go-toolchain.d.ts +20 -0
  21. package/dist/build-spk/go-toolchain.js +44 -0
  22. package/dist/build-spk/png.d.ts +14 -0
  23. package/dist/build-spk/png.js +161 -0
  24. package/dist/build-spk/ui-texts.d.ts +20 -0
  25. package/dist/build-spk/ui-texts.js +42 -0
  26. package/dist/cli.js +39 -9
  27. package/dist/contract/i18n.d.ts +5 -0
  28. package/dist/contract/i18n.js +45 -0
  29. package/dist/contract/icon.d.ts +5 -0
  30. package/dist/contract/icon.js +27 -0
  31. package/dist/contract/validate.js +4 -0
  32. package/dist/contract/web-launch.js +6 -2
  33. package/dist/deploy/native-install-values.d.ts +17 -0
  34. package/dist/deploy/native-install-values.js +32 -0
  35. package/dist/deploy/plan.d.ts +6 -2
  36. package/dist/deploy/plan.js +57 -6
  37. package/dist/error-registry.js +15 -6
  38. package/dist/host/dsm-client.js +53 -17
  39. package/dist/host/dsm-http.js +24 -2
  40. package/dist/host/types.d.ts +1 -0
  41. package/dist/info.js +28 -9
  42. package/dist/init.js +1 -1
  43. package/dist/runtime/dsm-version.d.ts +6 -0
  44. package/dist/runtime/dsm-version.js +8 -0
  45. package/dist/runtime/executor-build.d.ts +1 -1
  46. package/dist/runtime/executor-build.js +8 -5
  47. package/dist/standalone/daemon.d.ts +2 -1
  48. package/dist/standalone/daemon.js +10 -6
  49. package/dist/standalone/dsm-window.d.ts +2 -0
  50. package/dist/standalone/dsm-window.js +26 -40
  51. package/dist/standalone/i18n.d.ts +12 -0
  52. package/dist/standalone/i18n.js +30 -0
  53. package/dist/standalone/inspect-spk.d.ts +5 -0
  54. package/dist/standalone/inspect-spk.js +41 -2
  55. package/dist/standalone/manifest.d.ts +5 -6
  56. package/dist/standalone/manifest.js +21 -34
  57. package/dist/standalone/native-authorization-window.d.ts +1 -1
  58. package/dist/standalone/native-authorization-window.js +7 -1
  59. package/dist/standalone/native-layout.d.ts +2 -0
  60. package/dist/standalone/native-layout.js +22 -1
  61. package/dist/standalone/native-root-window.d.ts +4 -2
  62. package/dist/standalone/native-root-window.js +33 -18
  63. package/dist/standalone/root-window.d.ts +4 -1
  64. package/dist/standalone/root-window.js +143 -33
  65. package/dist/standalone/spk.js +7 -4
  66. package/dist/templates/_daemon/go.mod +1 -1
  67. package/dist/templates/_daemon/main.go +6 -3
  68. package/dist/templates/_daemon/main_test.go +21 -1
  69. package/dist/templates/_daemon/testdata/child.go +3 -0
  70. package/dist/templates/_executor/README.md +4 -1
  71. package/dist/templates/_executor/anchor.go +10 -0
  72. package/dist/templates/_executor/anchor_test.go +40 -5
  73. package/dist/templates/_executor/digest_review_test.go +2 -2
  74. package/dist/templates/_executor/dsm.go +40 -3
  75. package/dist/templates/_executor/go.mod +2 -0
  76. package/dist/templates/_executor/main.go +11 -5
  77. package/dist/templates/_executor/managed.go +3 -3
  78. package/dist/templates/_executor/managed_test.go +3 -3
  79. package/dist/templates/_executor/path_test.go +18 -0
  80. package/dist/templates/_executor/pid.go +37 -0
  81. package/dist/templates/_executor/pid_test.go +113 -0
  82. package/dist/templates/_executor/platform_other.go +8 -0
  83. package/dist/templates/_executor/purge_log.go +18 -0
  84. package/dist/templates/_executor/tasks.go +45 -7
  85. package/dist/templates/_executor/tasks_dsm71_test.go +114 -0
  86. package/dist/templates/_executor/tasks_test.go +58 -1
  87. package/dist/templates/_executor/upgrade_watch_test.go +13 -5
  88. package/dist/templates/_gateway/go.mod +2 -0
  89. package/dist/templates/binary/dsm-package.yaml +3 -0
  90. package/dist/templates/compose/synology-app.yaml +3 -0
  91. package/dist/templates/native-go/backend/go.mod +1 -1
  92. package/dist/templates/native-go/backend/internal/filestation/client.go +39 -3
  93. package/dist/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
  94. package/dist/templates/native-go/backend/internal/identity/README.md +5 -1
  95. package/dist/templates/native-go/backend/internal/identity/dsm.go +24 -5
  96. package/dist/templates/native-go/backend/internal/identity/http.go +1 -1
  97. package/dist/templates/native-go/backend/internal/identity/identity_test.go +48 -1
  98. package/dist/templates/native-go/backend/internal/identity/service.go +1 -1
  99. package/dist/templates/native-go/dsm-package.yaml +3 -0
  100. package/dist/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
  101. package/dist/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
  102. package/dist/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
  103. package/dist/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
  104. package/dist/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
  105. package/dist/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
  106. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
  107. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
  108. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
  109. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
  110. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
  111. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
  112. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
  113. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
  114. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
  115. package/package.json +2 -1
  116. package/schema/synology-app.schema.json +22 -0
  117. package/templates/_daemon/go.mod +1 -1
  118. package/templates/_daemon/main.go +6 -3
  119. package/templates/_daemon/main_test.go +21 -1
  120. package/templates/_daemon/testdata/child.go +3 -0
  121. package/templates/_executor/README.md +4 -1
  122. package/templates/_executor/anchor.go +10 -0
  123. package/templates/_executor/anchor_test.go +40 -5
  124. package/templates/_executor/digest_review_test.go +2 -2
  125. package/templates/_executor/dsm.go +40 -3
  126. package/templates/_executor/go.mod +2 -0
  127. package/templates/_executor/main.go +11 -5
  128. package/templates/_executor/managed.go +3 -3
  129. package/templates/_executor/managed_test.go +3 -3
  130. package/templates/_executor/path_test.go +18 -0
  131. package/templates/_executor/pid.go +37 -0
  132. package/templates/_executor/pid_test.go +113 -0
  133. package/templates/_executor/platform_other.go +8 -0
  134. package/templates/_executor/purge_log.go +18 -0
  135. package/templates/_executor/tasks.go +45 -7
  136. package/templates/_executor/tasks_dsm71_test.go +114 -0
  137. package/templates/_executor/tasks_test.go +58 -1
  138. package/templates/_executor/upgrade_watch_test.go +13 -5
  139. package/templates/_gateway/go.mod +2 -0
  140. package/templates/binary/dsm-package.yaml +3 -0
  141. package/templates/compose/synology-app.yaml +3 -0
  142. package/templates/native-go/backend/go.mod +1 -1
  143. package/templates/native-go/backend/internal/filestation/client.go +39 -3
  144. package/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
  145. package/templates/native-go/backend/internal/identity/README.md +5 -1
  146. package/templates/native-go/backend/internal/identity/dsm.go +24 -5
  147. package/templates/native-go/backend/internal/identity/http.go +1 -1
  148. package/templates/native-go/backend/internal/identity/identity_test.go +48 -1
  149. package/templates/native-go/backend/internal/identity/service.go +1 -1
  150. package/templates/native-go/dsm-package.yaml +3 -0
  151. package/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
  152. package/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
  153. package/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
  154. package/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
  155. package/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
  156. package/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
  157. package/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
  158. package/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
  159. package/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
  160. package/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
  161. package/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
  162. package/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
  163. package/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
  164. package/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
  165. package/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
@@ -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 f.valid && err == nil && c.Value == "valid-dsm-cookie" {
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.Context(), dc.Value)
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
- return cfg.executorRequest(ctx, "status", nil, nil)
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", "/run/synopkgland/"+cfg.PackageID+"/executor.sock")
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
- export interface CapabilityStatus {state:CapabilityState;reason?:string;requested?:{scope:string;generation:number}}
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",
@@ -10,6 +10,7 @@
10
10
  "ready": "バックグラウンドのファイルアクセスを利用できます。",
11
11
  "needs": "バックグラウンドのファイルアクセスには管理者の承認が必要です。",
12
12
  "blocked": "バックグラウンドのファイルアクセスを利用できません。アプリの設定を確認するよう管理者に依頼してください。",
13
+ "details": "詳細",
13
14
  "refresh": "最新の情報に更新",
14
15
  "revoke": "取り消す",
15
16
  "cancel": "ジョブをキャンセル",
@@ -10,6 +10,7 @@
10
10
  "ready": "백그라운드 파일 액세스를 사용할 수 있습니다.",
11
11
  "needs": "백그라운드 파일 액세스는 관리자의 승인이 필요합니다.",
12
12
  "blocked": "백그라운드 파일 액세스를 사용할 수 없습니다. 관리자에게 앱 설정을 확인하도록 요청하십시오.",
13
+ "details": "세부 사항",
13
14
  "refresh": "새로 고침",
14
15
  "revoke": "취소",
15
16
  "cancel": "작업 취소",
@@ -10,6 +10,7 @@
10
10
  "ready": "后台文件访问已就绪。",
11
11
  "needs": "需要管理员批准后台文件访问。",
12
12
  "blocked": "后台文件访问不可用,请管理员检查 App 设置。",
13
+ "details": "详情",
13
14
  "refresh": "刷新",
14
15
  "revoke": "撤销",
15
16
  "cancel": "取消任务",
@@ -10,6 +10,7 @@
10
10
  "ready": "背景檔案存取已就緒。",
11
11
  "needs": "需要管理員核准背景檔案存取。",
12
12
  "blocked": "背景檔案存取無法使用,請管理員檢查 App 設定。",
13
+ "details": "詳情",
13
14
  "refresh": "重新整理",
14
15
  "revoke": "撤銷",
15
16
  "cancel": "取消工作",