@synopackageland/templates 0.27.0 → 0.27.1

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 (29) hide show
  1. package/README.md +17 -14
  2. package/package.json +1 -1
  3. package/templates/_shared/AGENTS.md +5 -3
  4. package/templates/native-go/README.md +10 -3
  5. package/templates/native-go/backend/internal/app/background.go +52 -48
  6. package/templates/native-go/backend/internal/app/background_owner_test.go +2 -2
  7. package/templates/native-go/backend/internal/app/background_schedule_test.go +0 -4
  8. package/templates/native-go/backend/internal/app/background_test.go +59 -104
  9. package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +3 -20
  10. package/templates/native-go-jobs/frontend/test/{background-page.test.ts → background-shell.test.ts} +2 -4
  11. package/templates/native-go-vue/frontend/DESIGN.md +6 -9
  12. package/templates/native-go-vue/frontend/src/App.vue +0 -1
  13. package/templates/native-go-vue/frontend/src/i18n/de.json +1 -2
  14. package/templates/native-go-vue/frontend/src/i18n/en.json +1 -2
  15. package/templates/native-go-vue/frontend/src/i18n/es.json +1 -2
  16. package/templates/native-go-vue/frontend/src/i18n/fr.json +1 -2
  17. package/templates/native-go-vue/frontend/src/i18n/it.json +1 -2
  18. package/templates/native-go-vue/frontend/src/i18n/ja.json +1 -2
  19. package/templates/native-go-vue/frontend/src/i18n/ko.json +1 -2
  20. package/templates/native-go-vue/frontend/src/i18n/zh-CN.json +1 -2
  21. package/templates/native-go-vue/frontend/src/i18n/zh-TW.json +1 -2
  22. package/templates/native-node/README.md +10 -4
  23. package/templates/native-node-jobs/backend/src/jobs.ts +4 -10
  24. package/templates/native-node-jobs/backend/test/jobs.test.ts +5 -4
  25. package/templates/native-python/README.md +10 -4
  26. package/templates/native-python-jobs/backend/app/jobs.py +2 -11
  27. package/templates/native-python-jobs/backend/tests/test_jobs.py +5 -4
  28. package/templates/native-rust/README.md +10 -4
  29. package/templates/native-rust-jobs/backend/src/jobs.rs +21 -15
package/README.md CHANGED
@@ -49,7 +49,7 @@ identity gateway and keeps its own settings in an administrator-only DSM window.
49
49
  | --- | --- |
50
50
  | `native-go` | Base: `dsm-package.yaml`, Go module (`backend/cmd/{app,background-worker}`, `backend/internal/app`, `backend/internal/api/permissions.json`), plain `ui/`, i18n |
51
51
  | `native-go-vue` | Vue shell in `frontend/` (`App.vue`, `main.ts`, app i18n, `CapabilityPanels.vue`, `ExecutorGate.vue`) |
52
- | `native-go-jobs` | Background job routes in `internal/app` and their page test |
52
+ | `native-go-jobs` | Background job routes in `internal/app` and a shell lifecycle test |
53
53
  | `native-go-delegate` | Delegate routes, the delegate background worker, the delegate `CapabilityPanels.vue` |
54
54
  | `native-go-root-ops` | The SDK's executor routes and `root.run` in `internal/app`, an example `root/whoami.sh`, and the Vue `ExecutorGate.vue` that holds the pages until the executor is approved |
55
55
  | `native-python` | Base: `dsm-package.yaml` (`backend.runtime: python`), the App in `backend/app` (`routes.py`, stub capability modules, `permissions.json`), unittest tests |
@@ -88,12 +88,10 @@ the single source of the SDK version and `frontend/package.json` pins
88
88
  so no UI peer override is needed. Tests use jsdom 30 for UI floating-layer styles.
89
89
 
90
90
  SDK 0.27.0 moves interactive NAS file operations to same-origin browser calls.
91
- The unused server-only `bg.browse` and file-import scaffold now return
92
- `unsupported`; custom file handlers need the SDK's
91
+ Custom file handlers use the SDK's
93
92
  [session transport migration](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.27.0/docs/dsm-session-transport.md).
94
- Protected executor scaffolds need a released CLI with SDK 0.27.0 executor/lifecycle
95
- digests; CLI 0.26.0 does not include them and refuses those builds. Rebuilt protected
96
- executors follow normal administrator approval. No NAS acceptance is claimed.
93
+ Use CLI 0.27.0 for the matching SDK 0.27.0 executor/lifecycle digests. Rebuilt
94
+ protected executors follow normal administrator approval. No NAS acceptance is claimed.
97
95
 
98
96
  UI and i18n text, including the DSM glossary, omit terminal sentence periods.
99
97
  Keep punctuation between sentences and intentional loading ellipses.
@@ -215,12 +213,17 @@ visible while reads run. The backend permissions remain authoritative.
215
213
 
216
214
  Native Node Apps use the official [`Node.js_v22`](https://www.synology.com/en-us/dsm/packages/Node.js_v22) package. The official release history lists DSM 7 series; installation also depends on the model and DSM version supported by Package Center. This dependency does not certify all DSM 7 releases or install Node.js on unsupported models.
217
215
 
218
- ### Temporarily disabled NAS hashing example
216
+ ### Background jobs
219
217
 
220
- SDK 0.27.0 no longer relays browser DSM sessions from a backend. This release
221
- explicitly disables the template NAS file hashing example: Go production
222
- `bg.browse`/`bg.jobs.submit` and bridge `hash.submit` return `unsupported` before
223
- reading a file or creating a job. The Vue background example page displays an
224
- unavailable notice. Existing job list/status/pause/resume/cancel/schedule APIs
225
- and workers remain available for App-owned input; no SDK API or special option
226
- is introduced. A safe browser-owned input flow is follow-up work.
218
+ The jobs overlays submit files the App's own producer has already imported into
219
+ its private data directory. Submit `{key, name, input}` through `bg.jobs.submit`
220
+ (Go) or `hash.submit` (Python, Node.js and Rust); `input` is the absolute path of
221
+ that App-owned file. The Go handler and the bridge runtime confine reads to the
222
+ App data directory and keep the SDK's bounded private-copy input contract.
223
+ There is no NAS browse/import example or automatic file submission in either UI shell.
224
+
225
+ Keep the job controls and package-account workers for progress, cancellation and
226
+ pause/resume. Existing Go schedule routes reprocess retained, successful job
227
+ copies with explicit consent; they do not read a fresh source file on each run.
228
+ The bridge examples expose submission, lookup and cancellation through
229
+ `bg.submit`, `bg.get` and `bg.cancel`; the SDK's existing service surface is unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/templates",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
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": {
@@ -110,9 +110,11 @@ may redistribute; never copy Synology's.
110
110
 
111
111
  ## Background jobs (`jobs`)
112
112
 
113
- The NAS hashing example is temporarily disabled pending browser-owned input
114
- migration. Do not use backend DSM session replay or `files.read` to restore it.
115
- Existing workers and job-control APIs remain available for App-owned input.
113
+ Submit only files the App's own producer has already imported into its private
114
+ data directory, with `{key, name, input}`. The existing worker processes a
115
+ bounded private copy. Schedules reprocess that retained copy with explicit
116
+ consent; they never reread a source file. Keep submission, progress and
117
+ cancellation on the existing SDK contracts.
116
118
 
117
119
  The worker runs the App's Work for one claimed job at a time, as the package
118
120
  account. The Work receives the job's Task: it reads the private input copy,
@@ -400,6 +400,13 @@ SDK's `docs/shared-folder.md` for the whole flow.
400
400
  | DSM | 7.1 or later declared (`os_min_ver`, change it with `init --min-dsm` or `build --min-dsm`). Installed and tested on DSM 7.4 and 7.3.2 (x86_64). armv7 was installed and tested on DSM 7.1.1 (`alpine4k`, Linux 3.10, where the bridge runs from the package tmp fallback) with administrator, AppPriv and unauthenticated requests; `armada38x` and `monaco` have not been run on a NAS. No x86_64 Linux 3.10 model has been tested. aarch64 SPKs are built the same way but have not been run on a NAS. |
401
401
  | Not supported | armv7 platforms only DSM 7.0/7.1 ship (`armada370`, `armada375`, `armadaxp`, `comcerto2k`); DSM 6, running anything as root without an administrator's approval |
402
402
 
403
- The NAS file hashing example is temporarily disabled pending browser-owned
404
- input migration. NAS import submission returns `unsupported` without reading a
405
- file or creating a job; existing worker and job-control APIs remain available.
403
+ ## Background job input
404
+
405
+ Jobs process an existing file imported by the App's producer into its private
406
+ data directory. The submission params are `{key, name, input}`: an idempotency
407
+ key, a display name and an absolute path inside that directory. The SDK keeps a
408
+ bounded private copy for the worker; no browser session or NAS picker is used.
409
+ Submit with `bg.jobs.submit`; use `bg.jobs.get` for progress and
410
+ `bg.jobs.cancel` to cancel. Pause/resume and existing schedule routes continue
411
+ to operate on retained imported copies. Schedules require explicit consent and
412
+ reprocess the fixed private copy, without rereading the source file.
@@ -4,16 +4,17 @@ import (
4
4
  "context"
5
5
  "encoding/json"
6
6
  "errors"
7
+ "io"
7
8
  "log"
8
9
  "net/http"
10
+ "os"
9
11
  "os/user"
10
- "path"
11
12
  "path/filepath"
13
+ "syscall"
12
14
  "time"
13
15
 
14
16
  "github.com/synopackageland/syno-pkg-land-sdk/api"
15
17
  "github.com/synopackageland/syno-pkg-land-sdk/background"
16
- "github.com/synopackageland/syno-pkg-land-sdk/filestation"
17
18
  )
18
19
 
19
20
  // workerFresh is how recent the worker heartbeat must be to count as running.
@@ -31,28 +32,20 @@ func osLookupUID(name string) string {
31
32
  return u.Uid
32
33
  }
33
34
 
34
- type backgroundFiles interface {
35
- Browse(ctx context.Context, c api.Caller, r *http.Request, folder string, offset int) (filestation.Page, error)
36
- ReadFile(ctx context.Context, c api.Caller, r *http.Request, folder, rel string) ([]byte, error)
37
- }
38
-
39
35
  type backgroundAPI struct {
40
- store *background.Store
41
- files backgroundFiles
36
+ store *background.Store
37
+ dataDir string
42
38
  }
43
39
 
44
- // BackgroundRoutes is the production assembly of the opt-in background jobs
45
- // API. The NAS hashing example is disabled until browser-owned input is wired.
46
- // Existing job control routes remain available. `synopkgland init --with jobs` wires it in through background.go's
47
- // replacement of background_none.go; nothing else references it.
40
+ // BackgroundRoutes serves job controls for App-owned imported input.
48
41
  func BackgroundRoutes(dataDir string) []api.Route {
49
- return newBackgroundRoutes(background.NewRequestStore(filepath.Join(dataDir, "background")), nil)
42
+ return newBackgroundRoutes(background.NewRequestStore(filepath.Join(dataDir, "background")), dataDir)
50
43
  }
51
44
 
52
45
  // Every request authenticates against DSM afresh. Owner routes are available
53
46
  // to signed-in users; schedule reapproval and owner cleanup are admin-only.
54
- func newBackgroundRoutes(store *background.Store, files backgroundFiles) []api.Route {
55
- b := backgroundAPI{store: store, files: files}
47
+ func newBackgroundRoutes(store *background.Store, dataDir string) []api.Route {
48
+ b := backgroundAPI{store: store, dataDir: dataDir}
56
49
  return []api.Route{
57
50
  api.HandleRequest("bg.status", b.owner(b.status)),
58
51
  api.HandleRequest("bg.schedules.list", b.owner(b.schedules)),
@@ -61,7 +54,6 @@ func newBackgroundRoutes(store *background.Store, files backgroundFiles) []api.R
61
54
  api.HandleRequest("bg.schedules.delete", b.owner(b.deleteSchedule)),
62
55
  api.HandleRequest("bg.schedules.enable", b.admin(b.enableSchedule)),
63
56
  api.HandleRequest("bg.jobs.purge", b.admin(b.purge)),
64
- api.HandleRequest("bg.browse", b.owner(b.browse)),
65
57
  api.HandleRequest("bg.jobs.submit", b.owner(b.submit)),
66
58
  api.HandleRequest("bg.jobs.list", b.owner(b.list)),
67
59
  api.HandleRequest("bg.jobs.get", b.owner(b.get)),
@@ -116,51 +108,28 @@ func workerAlive(w background.WorkerInfo, now time.Time) bool {
116
108
  return now.Sub(w.At) <= workerFresh
117
109
  }
118
110
 
119
- // browse lists what the caller's own File Station session can see, so
120
- // the panel can pick a file instead of typing a path. It grants nothing:
121
- // submit still reads through the same session.
122
- func (b backgroundAPI) browse(ctx context.Context, c api.Caller, r *http.Request, params json.RawMessage) (any, error) {
123
- var in struct {
124
- Folder string `json:"folder"`
125
- Offset int `json:"offset"`
126
- }
127
- if err := decodeParams(params, &in); err != nil {
128
- return nil, err
129
- }
130
- if in.Offset < 0 {
131
- return nil, api.BadRequest("offset must not be negative")
132
- }
133
- if b.files == nil {
134
- return nil, &api.Error{Code: "unsupported", Message: "The NAS file hashing example is disabled pending browser input migration. Existing job control APIs remain available."}
135
- }
136
- return b.files.Browse(ctx, c, r, in.Folder, in.Offset)
137
- }
138
-
139
- func (b backgroundAPI) submit(ctx context.Context, c api.Caller, r *http.Request, params json.RawMessage) (any, error) {
111
+ func (b backgroundAPI) submit(ctx context.Context, _ api.Caller, _ *http.Request, params json.RawMessage) (any, error) {
140
112
  var in struct {
141
- Key string `json:"key"`
142
- Folder string `json:"folder"`
143
- Relative string `json:"relative"`
113
+ Key string `json:"key"`
114
+ Name string `json:"name"`
115
+ Input string `json:"input"`
144
116
  }
145
117
  if err := decodeParams(params, &in); err != nil {
146
118
  return nil, err
147
119
  }
148
- if in.Key == "" || in.Folder == "" || in.Relative == "" {
149
- return nil, api.BadRequest("key, folder and relative are required")
120
+ if in.Key == "" || in.Name == "" || in.Input == "" {
121
+ return nil, api.BadRequest("key, name and input are required")
150
122
  }
151
123
  access := accessFrom(ctx)
152
124
  if !access.Owner.Valid() {
153
125
  return nil, &api.Error{Code: "identity_unavailable", Message: "DSM user UID could not be resolved"}
154
126
  }
155
- if b.files == nil {
156
- return nil, &api.Error{Code: "unsupported", Message: "The NAS file hashing example is disabled pending browser input migration. Existing job control APIs remain available."}
157
- }
158
- data, err := b.files.ReadFile(ctx, c, r, in.Folder, in.Relative)
127
+ data, err := b.readInput(in.Input)
159
128
  if err != nil {
160
129
  return nil, err
161
130
  }
162
131
  job, duplicate, err := b.store.Submit(ctx, background.SubmitRequest{
163
- Key: in.Key, Name: path.Base(in.Relative), Data: data, Submitter: access.Owner,
132
+ Key: in.Key, Name: in.Name, Data: data, Submitter: access.Owner,
164
133
  })
165
134
  if err != nil {
166
135
  return nil, backgroundError(err)
@@ -168,6 +137,41 @@ func (b backgroundAPI) submit(ctx context.Context, c api.Caller, r *http.Request
168
137
  return map[string]any{"job": job, "duplicate": duplicate}, nil
169
138
  }
170
139
 
140
+ // readInput accepts only an existing regular file inside the App data directory.
141
+ // The App's producer imports it first; jobs and schedules retain a private copy.
142
+ func (b backgroundAPI) readInput(input string) ([]byte, error) {
143
+ bad := api.BadRequest("input must be a regular file in the App data directory")
144
+ rel, err := filepath.Rel(b.dataDir, input)
145
+ if err != nil || !filepath.IsAbs(input) || !filepath.IsLocal(rel) {
146
+ return nil, bad
147
+ }
148
+ root, err := os.OpenRoot(b.dataDir)
149
+ if err != nil {
150
+ return nil, bad
151
+ }
152
+ defer root.Close()
153
+ f, err := root.OpenFile(rel, os.O_RDONLY|syscall.O_NONBLOCK, 0)
154
+ if err != nil {
155
+ return nil, bad
156
+ }
157
+ defer f.Close()
158
+ info, err := f.Stat()
159
+ if err != nil || !info.Mode().IsRegular() {
160
+ return nil, bad
161
+ }
162
+ if info.Size() > background.MaxInputBytes {
163
+ return nil, backgroundError(background.ErrTooLarge)
164
+ }
165
+ data, err := io.ReadAll(io.LimitReader(f, background.MaxInputBytes+1))
166
+ if err != nil {
167
+ return nil, bad
168
+ }
169
+ if len(data) > background.MaxInputBytes {
170
+ return nil, backgroundError(background.ErrTooLarge)
171
+ }
172
+ return data, nil
173
+ }
174
+
171
175
  func (b backgroundAPI) list(ctx context.Context, _ api.Caller, _ *http.Request, params json.RawMessage) (any, error) {
172
176
  if err := decodeParams(params, &struct{}{}); err != nil {
173
177
  return nil, err
@@ -28,7 +28,7 @@ var carol = stubAuth{identity: api.Identity{User: "carol"}}
28
28
 
29
29
  func ownerSubmit(t *testing.T, e *bgEnv, a api.Authenticator, key string) string {
30
30
  t.Helper()
31
- code, out := e.call(a, "bg.jobs.submit", `{"key":"`+key+`","folder":"/share","relative":"a.txt"}`)
31
+ code, out := e.call(a, "bg.jobs.submit", e.submitParams(key))
32
32
  if code != "" {
33
33
  t.Fatalf("submit %s: %s %v", key, code, out)
34
34
  }
@@ -103,7 +103,7 @@ func TestBackgroundPersonalQuotaAndAdminPurge(t *testing.T) {
103
103
  }
104
104
  ownerSubmit(t, e, bob, "one")
105
105
  ownerSubmit(t, e, bob, "two")
106
- if code, _ := e.call(bob, "bg.jobs.submit", `{"key":"three","folder":"/share","relative":"a.txt"}`); code != "owner-queue-full" {
106
+ if code, _ := e.call(bob, "bg.jobs.submit", e.submitParams("three")); code != "owner-queue-full" {
107
107
  t.Fatal(code)
108
108
  }
109
109
  ownerSubmit(t, e, carol, "one")
@@ -55,8 +55,4 @@ func TestBackgroundScheduleRoutesConsentOwnerAndAdmin(t *testing.T) {
55
55
  if code, _ := e.call(bob, "bg.schedules.delete", fmt.Sprintf(`{"id":%q}`, sid)); code != "" {
56
56
  t.Fatal(code)
57
57
  }
58
- // No scheduling route calls File Station, uses a root grant or stores a session.
59
- if e.files.calls != 1 {
60
- t.Fatalf("schedule read File Station %d", e.files.calls)
61
- }
62
58
  }
@@ -14,50 +14,30 @@ import (
14
14
 
15
15
  "github.com/synopackageland/syno-pkg-land-sdk/api"
16
16
  "github.com/synopackageland/syno-pkg-land-sdk/background"
17
- "github.com/synopackageland/syno-pkg-land-sdk/filestation"
18
17
  )
19
18
 
20
- type fakeFiles struct {
21
- data []byte
22
- err error
23
- calls int
24
- folder string
25
- rel string
26
- offset int
27
- page filestation.Page
28
- }
29
-
30
- func (f *fakeFiles) Browse(_ context.Context, _ api.Caller, _ *http.Request, folder string, offset int) (filestation.Page, error) {
31
- f.calls++
32
- f.folder, f.offset = folder, offset
33
- return f.page, f.err
34
- }
35
-
36
- func (f *fakeFiles) ReadFile(_ context.Context, _ api.Caller, _ *http.Request, folder, rel string) ([]byte, error) {
37
- f.calls++
38
- f.folder, f.rel = folder, rel
39
- return f.data, f.err
40
- }
41
-
42
19
  type bgEnv struct {
43
- t *testing.T
44
- store *background.Store
45
- files *fakeFiles
46
- srv map[string]*api.Server
20
+ t *testing.T
21
+ store *background.Store
22
+ dataDir string
23
+ srv map[string]*api.Server
47
24
  }
48
25
 
49
26
  func newBgEnv(t *testing.T) *bgEnv {
50
27
  lookupSubmitterUID = func(string) string { return "1024" }
51
28
  t.Cleanup(func() { lookupSubmitterUID = osLookupUID })
52
- store := background.NewStore(filepath.Join(t.TempDir(), "background"))
53
- files := &fakeFiles{data: []byte("hello")}
54
- return &bgEnv{t: t, store: store, files: files, srv: map[string]*api.Server{}}
29
+ dir := t.TempDir()
30
+ store := background.NewStore(filepath.Join(dir, "background"))
31
+ if err := os.WriteFile(filepath.Join(dir, "input.txt"), []byte("hello"), 0600); err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ return &bgEnv{t: t, store: store, dataDir: dir, srv: map[string]*api.Server{}}
55
35
  }
56
36
 
57
37
  func (e *bgEnv) call(auth api.Authenticator, method, params string) (string, map[string]any) {
58
38
  e.t.Helper()
59
39
  // SDK policy provides owner routes immediately.
60
- routes := newBackgroundRoutes(e.store, e.files)
40
+ routes := newBackgroundRoutes(e.store, e.dataDir)
61
41
  srv, err := api.NewServerWithPermissions(auth, []byte(`{"schema":1,"default":"administrators","methods":{}}`), routes...)
62
42
  if err != nil {
63
43
  e.t.Fatal(err)
@@ -88,8 +68,7 @@ var bgSample = map[string]string{
88
68
  "bg.schedules.enable": `{"id":"missing","consent":true}`,
89
69
  "bg.status": `{}`,
90
70
  "bg.jobs.purge": `{"owner":{"user":"bob","uid":"1025"}}`,
91
- "bg.browse": `{"folder":"/share","offset":0}`,
92
- "bg.jobs.submit": `{"key":"k1","folder":"/share","relative":"a.txt"}`,
71
+ "bg.jobs.submit": `{"key":"k1","name":"a.txt","input":"/missing"}`,
93
72
  "bg.jobs.list": `{}`,
94
73
  "bg.jobs.get": `{"id":"` + strings.Repeat("a", 32) + `"}`,
95
74
  "bg.jobs.cancel": `{"id":"` + strings.Repeat("a", 32) + `"}`,
@@ -100,7 +79,7 @@ var bgSample = map[string]string{
100
79
 
101
80
  func TestBackgroundSDKPoliciesAuthenticateEveryRoute(t *testing.T) {
102
81
  e := newBgEnv(t)
103
- routes := newBackgroundRoutes(e.store, e.files)
82
+ routes := newBackgroundRoutes(e.store, e.dataDir)
104
83
  if len(routes) != len(bgSample) {
105
84
  t.Fatalf("%d routes, %d samples", len(routes), len(bgSample))
106
85
  }
@@ -149,55 +128,73 @@ func TestBackgroundStatusReportsLimitsAndWorker(t *testing.T) {
149
128
  }
150
129
  }
151
130
 
152
- func TestBackgroundSubmitAvailableOnFreshStore(t *testing.T) {
153
- e := newBgEnv(t)
154
- code, _ := e.call(alice, "bg.jobs.submit", bgSample["bg.jobs.submit"])
155
- if code != "" || e.files.calls != 1 {
156
- t.Fatalf("%q, NAS reads %d", code, e.files.calls)
131
+ func (e *bgEnv) submitParams(key string) string {
132
+ p, err := json.Marshal(map[string]string{"key": key, "name": "a.txt", "input": filepath.Join(e.dataDir, "input.txt")})
133
+ if err != nil {
134
+ e.t.Fatal(err)
157
135
  }
136
+ return string(p)
158
137
  }
159
138
 
160
- func TestBackgroundSubmitImportsSelectedFileAndIsIdempotent(t *testing.T) {
139
+ func TestBackgroundSubmitImportsAppInputAndIsIdempotent(t *testing.T) {
161
140
  e := newBgEnv(t)
162
- params := `{"key":"k1","folder":"/share","relative":"docs/a.txt"}`
141
+ params := e.submitParams("k1")
163
142
  code, out := e.call(alice, "bg.jobs.submit", params)
164
- job := data(out)["job"].(map[string]any)
165
- if code != "" || job["state"] != "queued" || job["name"] != "a.txt" || data(out)["duplicate"] != false {
143
+ if code != "" {
166
144
  t.Fatalf("%s %v", code, out)
167
145
  }
168
- if e.files.folder != "/share" || e.files.rel != "docs/a.txt" {
169
- t.Fatalf("adapter saw %q %q", e.files.folder, e.files.rel)
146
+ job := data(out)["job"].(map[string]any)
147
+ if job["state"] != "queued" || job["name"] != "a.txt" || data(out)["duplicate"] != false {
148
+ t.Fatal(out)
170
149
  }
171
150
  submitter := job["submitter"].(map[string]any)
172
151
  if submitter["user"] != "alice" || submitter["uid"] != "1024" {
173
- t.Fatalf("submitter: %v", submitter)
152
+ t.Fatal(submitter)
174
153
  }
175
154
  _, again := e.call(alice, "bg.jobs.submit", params)
176
155
  if data(again)["duplicate"] != true || data(again)["job"].(map[string]any)["id"] != job["id"] {
177
- t.Fatalf("duplicate: %v", again)
156
+ t.Fatal(again)
157
+ }
158
+ if err := os.WriteFile(filepath.Join(e.dataDir, "input.txt"), []byte("other"), 0600); err != nil {
159
+ t.Fatal(err)
178
160
  }
179
- e.files.data = []byte("other")
180
161
  if code, _ := e.call(alice, "bg.jobs.submit", params); code != "conflict" {
181
- t.Fatalf("same key with different bytes: %q", code)
162
+ t.Fatalf("different bytes: %q", code)
182
163
  }
183
164
  }
184
165
 
185
- func TestBackgroundSubmitPassesAdapterRefusalsThrough(t *testing.T) {
166
+ func TestBackgroundSubmitRejectsExternalAndOversizedInput(t *testing.T) {
186
167
  e := newBgEnv(t)
187
- e.files.err = &api.Error{Code: "limit-exceeded", Message: "x"}
188
- if code, _ := e.call(alice, "bg.jobs.submit", bgSample["bg.jobs.submit"]); code != "limit-exceeded" {
189
- t.Fatalf("%q", code)
168
+ outside := filepath.Join(t.TempDir(), "outside")
169
+ if err := os.WriteFile(outside, []byte("private"), 0600); err != nil {
170
+ t.Fatal(err)
190
171
  }
191
- if jobs, _ := e.store.List(context.Background()); len(jobs) != 0 {
192
- t.Fatal("a failed import must never enter the queue")
172
+ if err := os.Symlink(outside, filepath.Join(e.dataDir, "escape")); err != nil {
173
+ t.Fatal(err)
174
+ }
175
+ for _, input := range []string{outside, "input.txt", filepath.Join(e.dataDir, "escape"), e.dataDir} {
176
+ p, _ := json.Marshal(map[string]string{"key": "bad", "name": "a.txt", "input": input})
177
+ if code, _ := e.call(alice, "bg.jobs.submit", string(p)); code != "bad_request" {
178
+ t.Fatalf("%s: %s", input, code)
179
+ }
180
+ }
181
+ if err := os.WriteFile(filepath.Join(e.dataDir, "input.txt"), make([]byte, background.MaxInputBytes+1), 0600); err != nil {
182
+ t.Fatal(err)
183
+ }
184
+ if code, _ := e.call(alice, "bg.jobs.submit", e.submitParams("big")); code != "limit-exceeded" {
185
+ t.Fatal(code)
186
+ }
187
+ jobs, err := e.store.List(context.Background())
188
+ if err != nil || len(jobs) != 0 {
189
+ t.Fatalf("invalid input queued: %v %v", jobs, err)
193
190
  }
194
191
  }
195
192
 
196
193
  func TestBackgroundSubmitValidatesParams(t *testing.T) {
197
194
  e := newBgEnv(t)
198
195
  for _, p := range []string{
199
- `{}`, `{"key":"k","folder":"/share","relative":"a","extra":1}`, `{"key":"bad key","folder":"/share","relative":"a"}`,
200
- `{"key":"k","folder":"/share","relative":""}`,
196
+ `{}`, `{"key":"k","name":"a","input":"/a","extra":1}`, e.submitParams("bad key"),
197
+ `{"key":"k","name":"a","input":""}`,
201
198
  } {
202
199
  if code, _ := e.call(alice, "bg.jobs.submit", p); code != "bad_request" {
203
200
  t.Errorf("%s: %q", p, code)
@@ -210,19 +207,19 @@ func TestBackgroundQueueFullAndQuotaErrorsHaveStableCodes(t *testing.T) {
210
207
  for i := 0; i < background.MaxQueued; i++ {
211
208
  lookupSubmitterUID = func(string) string { return fmt.Sprint(1024 + i/2) }
212
209
  key := "k" + string(rune('a'+i))
213
- if code, _ := e.call(alice, "bg.jobs.submit", `{"key":"`+key+`","folder":"/share","relative":"a.txt"}`); code != "" {
210
+ if code, _ := e.call(alice, "bg.jobs.submit", e.submitParams(key)); code != "" {
214
211
  t.Fatalf("%d: %q", i, code)
215
212
  }
216
213
  }
217
214
  lookupSubmitterUID = func(string) string { return "9999" }
218
- if code, _ := e.call(alice, "bg.jobs.submit", `{"key":"more","folder":"/share","relative":"a.txt"}`); code != "queue-full" {
215
+ if code, _ := e.call(alice, "bg.jobs.submit", e.submitParams("more")); code != "queue-full" {
219
216
  t.Fatalf("%q", code)
220
217
  }
221
218
  }
222
219
 
223
220
  func TestBackgroundListGetCancelAndResult(t *testing.T) {
224
221
  e := newBgEnv(t)
225
- _, out := e.call(alice, "bg.jobs.submit", `{"key":"k1","folder":"/share","relative":"a.txt"}`)
222
+ _, out := e.call(alice, "bg.jobs.submit", e.submitParams("k1"))
226
223
  id := data(out)["job"].(map[string]any)["id"].(string)
227
224
  idp := `{"id":"` + id + `"}`
228
225
 
@@ -260,7 +257,7 @@ func TestBackgroundListGetCancelAndResult(t *testing.T) {
260
257
 
261
258
  func TestBackgroundCancelQueuedJob(t *testing.T) {
262
259
  e := newBgEnv(t)
263
- _, out := e.call(alice, "bg.jobs.submit", `{"key":"k1","folder":"/share","relative":"a.txt"}`)
260
+ _, out := e.call(alice, "bg.jobs.submit", e.submitParams("k1"))
264
261
  id := data(out)["job"].(map[string]any)["id"].(string)
265
262
  code, out := e.call(alice, "bg.jobs.cancel", `{"id":"`+id+`"}`)
266
263
  if code != "" || data(out)["job"].(map[string]any)["state"] != "canceled" {
@@ -270,7 +267,7 @@ func TestBackgroundCancelQueuedJob(t *testing.T) {
270
267
 
271
268
  func TestBackgroundPauseAndResumeQueuedJob(t *testing.T) {
272
269
  e := newBgEnv(t)
273
- _, out := e.call(alice, "bg.jobs.submit", `{"key":"k1","folder":"/share","relative":"a.txt"}`)
270
+ _, out := e.call(alice, "bg.jobs.submit", e.submitParams("k1"))
274
271
  idp := `{"id":"` + data(out)["job"].(map[string]any)["id"].(string) + `"}`
275
272
  for method, state := range map[string]string{"bg.jobs.pause": "paused", "bg.jobs.resume": "queued"} {
276
273
  if code, out := e.call(alice, method, idp); code != "" || data(out)["job"].(map[string]any)["state"] != state {
@@ -296,48 +293,6 @@ func TestBackgroundWorkerAliveExpires(t *testing.T) {
296
293
  }
297
294
  }
298
295
 
299
- func TestBackgroundBrowseListsThroughTheCallersSession(t *testing.T) {
300
- e := newBgEnv(t)
301
- e.files.page = filestation.Page{Files: []filestation.Entry{{Name: "a.txt", Path: "/share/a.txt"}}, Total: 1}
302
- code, out := e.call(alice, "bg.browse", `{"folder":"/share","offset":100}`)
303
- if code != "" {
304
- t.Fatalf("%s %v", code, out)
305
- }
306
- if e.files.folder != "/share" || e.files.offset != 100 {
307
- t.Fatalf("browsed %q at %d", e.files.folder, e.files.offset)
308
- }
309
- files := data(out)["files"].([]any)
310
- if len(files) != 1 || files[0].(map[string]any)["path"] != "/share/a.txt" {
311
- t.Fatalf("page %v", out)
312
- }
313
- }
314
-
315
- func TestBackgroundBrowseWithoutFolderListsTheShares(t *testing.T) {
316
- e := newBgEnv(t)
317
- if code, out := e.call(alice, "bg.browse", `{}`); code != "" {
318
- t.Fatalf("%s %v", code, out)
319
- }
320
- if e.files.folder != "" || e.files.offset != 0 {
321
- t.Fatalf("browsed %q at %d", e.files.folder, e.files.offset)
322
- }
323
- }
324
-
325
- func TestBackgroundBrowseValidatesParamsAndPassesRefusalsThrough(t *testing.T) {
326
- e := newBgEnv(t)
327
- for _, params := range []string{`{"offset":-1}`, `{"folder":"/share","extra":1}`, `{"offset":"1"}`} {
328
- if code, _ := e.call(alice, "bg.browse", params); code != "bad_request" {
329
- t.Errorf("%s: %q", params, code)
330
- }
331
- }
332
- if e.files.calls != 0 {
333
- t.Fatal("invalid params must not reach the NAS")
334
- }
335
- e.files.err = &api.Error{Code: "denied", Message: "no"}
336
- if code, _ := e.call(alice, "bg.browse", `{"folder":"/share"}`); code != "denied" {
337
- t.Fatalf("got %q", code)
338
- }
339
- }
340
-
341
296
  func TestBackgroundListWithoutJobsIsAnEmptyArray(t *testing.T) {
342
297
  e := newBgEnv(t)
343
298
  _, out := e.call(alice, "bg.jobs.list", `{}`)
@@ -16,7 +16,6 @@ import (
16
16
 
17
17
  func init() {
18
18
  // The generic route tests need valid params for every background route.
19
- // The SDK refuses retired backend session relays before reading a file.
20
19
  // No jobs or schedules exist in its fresh data directory.
21
20
  const id = `{"id":"missing"}`
22
21
  sampleParams["bg.jobs.purge"] = `{"owner":{"user":"missing","uid":"9999"}}`
@@ -30,16 +29,14 @@ func init() {
30
29
  sampleOutcome["bg.schedules.delete"] = "not_found"
31
30
  sampleOutcome["bg.schedules.enable"] = "not_found"
32
31
  sampleParams["bg.status"] = `{}`
33
- sampleParams["bg.browse"] = `{}`
34
- sampleParams["bg.jobs.submit"] = `{"key":"k1","folder":"/share","relative":"a.bin"}`
32
+ sampleParams["bg.jobs.submit"] = `{"key":"k1","name":"a.bin","input":"/missing"}`
35
33
  sampleParams["bg.jobs.list"] = `{}`
36
34
  sampleParams["bg.jobs.get"] = id
37
35
  sampleParams["bg.jobs.cancel"] = id
38
36
  sampleParams["bg.jobs.pause"] = id
39
37
  sampleParams["bg.jobs.resume"] = id
40
38
  sampleParams["bg.jobs.result"] = id
41
- sampleOutcome["bg.browse"] = "unsupported"
42
- sampleOutcome["bg.jobs.submit"] = "unsupported"
39
+ sampleOutcome["bg.jobs.submit"] = "bad_request"
43
40
  sampleOutcome["bg.jobs.get"] = "not_found"
44
41
  sampleOutcome["bg.jobs.cancel"] = "not_found"
45
42
  sampleOutcome["bg.jobs.pause"] = "not_found"
@@ -72,7 +69,7 @@ func TestBackgroundRequestSubmitBusyAndProductionConstruction(t *testing.T) {
72
69
  }
73
70
  defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
74
71
  start := time.Now()
75
- code, out := e.call(alice, "bg.jobs.submit", bgSample["bg.jobs.submit"])
72
+ code, out := e.call(alice, "bg.jobs.submit", e.submitParams("k1"))
76
73
  if code != "jobs_busy" || time.Since(start) > background.RequestLockTimeout+time.Second {
77
74
  t.Fatalf("busy submit: %s %+v", code, out)
78
75
  }
@@ -90,17 +87,3 @@ func TestBackgroundRequestSubmitBusyAndProductionConstruction(t *testing.T) {
90
87
  t.Fatal(w.Body.String())
91
88
  }
92
89
  }
93
-
94
- func TestProductionNASHashExampleRefusesWithoutCreatingJobs(t *testing.T) {
95
- cfg := testConfig(t)
96
- for _, method := range []string{"bg.browse", "bg.jobs.submit"} {
97
- code, _ := call(t, cfg, alice, method, sampleParams[method])
98
- if code != "unsupported" {
99
- t.Fatalf("%s: %s", method, code)
100
- }
101
- }
102
- jobs, err := background.NewStore(filepath.Join(cfg.DataDir, "background")).List(context.Background())
103
- if err != nil || len(jobs) != 0 {
104
- t.Fatalf("disabled example created jobs: %v %v", jobs, err)
105
- }
106
- }
@@ -4,8 +4,7 @@ import {nextTick} from 'vue'
4
4
  import {mount} from '../src/main'
5
5
  import {dsmWindow} from './context'
6
6
  async function settle(){for(let i=0;i<20;i++){await Promise.resolve();await nextTick()}}
7
- // The file-import example is explicitly unavailable until browser input is migrated.
8
- test('a jobs project explains the disabled example without submitting or reading files',async()=>{
7
+ test('a jobs project opens its shell without starting a job',async()=>{
9
8
  const call=vi.fn(async(method:string)=>{
10
9
  if(method==='app.status')return {package:'Jobs',version:'1',user:'alice',admin:false}
11
10
  throw {code:'unknown_method'}
@@ -14,8 +13,7 @@ test('a jobs project explains the disabled example without submitting or reading
14
13
  const app=mount(root,dsmWindow({call}))
15
14
  try{
16
15
  await settle()
17
- expect(root.querySelector('[data-background-example-disabled]')?.textContent).toContain('disabled')
18
- expect(root.querySelector('[data-background-panel]')).toBeNull()
16
+ expect(root.querySelector('[data-app-status]')).not.toBeNull()
19
17
  expect(call.mock.calls.some(([method])=>method.startsWith('bg.'))).toBe(false)
20
18
  }finally{app.unmount();root.remove()}
21
19
  })
@@ -43,16 +43,13 @@ Refresh and save expose busy state and localized errors. Reopen refreshes status
43
43
  Each mount owns its state; unmount unsubscribes from host events and ignores late
44
44
  responses. Backend route policies decide access.
45
45
 
46
- ### Opt-in background schedules (S1-a)
46
+ ### Background jobs
47
47
 
48
- The `native-go-jobs` overlay extends this **Operate** surface within the inherited `AppRoot`, embedded theme, `SettingsSection`, Button and Callout system. It introduces no new palette or global tokens.
49
-
50
- - **Private-copy consent:** “Schedule this copy” is available for the signed-in owner's retained, successful, unscheduled job copy. “Private copy: {name}” identifies the input; “Repeat every” offers “Hour”, “Day” and “Week”. “Create schedule” stays disabled until the user checks “I agree to keep and reprocess this fixed copy as the package user. No new source files or personal ACLs are read.” The help explains the eight-schedule limit and that missed runs are recorded, never replayed.
51
- - **Ownership and recovery:** preserve user-and-UID owner isolation. Administrator rows expose “Owner: {user} (UID {uid})”. A worker change shows “The worker revision changed. An administrator must review and explicitly enable this schedule.” Only administrators, while background mode is on, see “Enable schedule”. Its confirmation names the exact private copy: “Enable the schedule for {name} with the current worker revision? I agree to reprocess this fixed copy. Past runs will not be replayed.” Actions are “Confirm enable” and “Cancel”.
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
- - **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
-
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.
48
+ Job producers import their own input into the App data directory. The backend
49
+ keeps submission, progress, cancellation and private-copy scheduling contracts;
50
+ the shell does not pick or import NAS files. App-specific job controls can use
51
+ the installed SDK and UI components. Keep explicit consent for schedules and
52
+ user-and-UID ownership isolation, and stop polling on unmount.
56
53
 
57
54
  ## Do's and Don'ts
58
55
 
@@ -121,7 +121,6 @@ onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose();text.value=''})
121
121
  <CapabilityPanels v-if="app" ref="panels" :key="app.user" :ctx="ctx" :admin="app.admin" />
122
122
  <FilesPage v-if="page==='status'" :ctx="ctx" />
123
123
  <div data-page="background" v-show="page==='background'">
124
- <Callout v-if="backgroundEnabled && app" tone="warning" data-background-example-disabled>{{ copy.backgroundExampleUnavailable }}</Callout>
125
124
  </div>
126
125
  </div>
127
126
  </ExecutorGate>
@@ -17,6 +17,5 @@
17
17
  },
18
18
  "packageName": "Paketname",
19
19
  "user": "Benutzer",
20
- "version": "Version",
21
- "backgroundExampleUnavailable": "Das NAS-Datei-Hash-Beispiel ist bis zur Umstellung auf Browser-Eingaben deaktiviert. Bestehende APIs zur Auftragssteuerung bleiben verfügbar"
20
+ "version": "Version"
22
21
  }
@@ -17,6 +17,5 @@
17
17
  },
18
18
  "packageName": "Package name",
19
19
  "user": "User",
20
- "version": "Version",
21
- "backgroundExampleUnavailable": "The NAS file hashing example is disabled pending browser input migration. Existing job control APIs remain available"
20
+ "version": "Version"
22
21
  }
@@ -17,6 +17,5 @@
17
17
  },
18
18
  "packageName": "Nombre del paquete",
19
19
  "user": "Usuario",
20
- "version": "Versión",
21
- "backgroundExampleUnavailable": "El ejemplo de hash de archivos NAS está desactivado hasta migrar la entrada al navegador. Las API de control de trabajos siguen disponibles"
20
+ "version": "Versión"
22
21
  }
@@ -17,6 +17,5 @@
17
17
  },
18
18
  "packageName": "Nom du paquet",
19
19
  "user": "Utilisateur",
20
- "version": "Version",
21
- "backgroundExampleUnavailable": "L’exemple de hachage de fichiers NAS est désactivé en attendant la migration des entrées du navigateur. Les API de contrôle des tâches restent disponibles"
20
+ "version": "Version"
22
21
  }
@@ -17,6 +17,5 @@
17
17
  },
18
18
  "packageName": "Nome pacchetto",
19
19
  "user": "Utente",
20
- "version": "Versione",
21
- "backgroundExampleUnavailable": "L’esempio di hashing dei file NAS è disabilitato in attesa della migrazione dell’input dal browser. Le API di controllo dei processi restano disponibili"
20
+ "version": "Versione"
22
21
  }
@@ -17,6 +17,5 @@
17
17
  },
18
18
  "packageName": "パッケージ名",
19
19
  "user": "ユーザー",
20
- "version": "バージョン",
21
- "backgroundExampleUnavailable": "NAS ファイルのハッシュ例はブラウザー入力への移行まで無効です。既存のジョブ制御 API は利用できます"
20
+ "version": "バージョン"
22
21
  }
@@ -17,6 +17,5 @@
17
17
  },
18
18
  "packageName": "패키지 이름",
19
19
  "user": "사용자",
20
- "version": "버전",
21
- "backgroundExampleUnavailable": "브라우저 입력 전환이 완료될 때까지 NAS 파일 해시 예제를 사용할 수 없습니다. 기존 작업 제어 API는 계속 사용할 수 있습니다"
20
+ "version": "버전"
22
21
  }
@@ -17,6 +17,5 @@
17
17
  },
18
18
  "packageName": "套件名称",
19
19
  "user": "用户帐号",
20
- "version": "版本",
21
- "backgroundExampleUnavailable": "本版本暂停 NAS 文件哈希示例,待浏览器输入流程完成后恢复。现有任务控制 API 仍可使用"
20
+ "version": "版本"
22
21
  }
@@ -17,6 +17,5 @@
17
17
  },
18
18
  "packageName": "套件名稱",
19
19
  "user": "使用者帳號",
20
- "version": "版本",
21
- "backgroundExampleUnavailable": "本版本暫停 NAS 檔案雜湊範例,待瀏覽器輸入流程完成後恢復。既有工作控制 API 仍可使用"
20
+ "version": "版本"
22
21
  }
@@ -111,7 +111,7 @@ rebuilding the SPK.
111
111
 
112
112
  | `--with` | Methods | Runtime services |
113
113
  | --- | --- | --- |
114
- | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | `hash.submit` is disabled pending browser input migration; `bg.get` and `bg.cancel` remain available |
114
+ | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | Submit an App-owned imported file with `{key, name, input}` via `bg.submit`; look up or cancel the caller's job via `bg.get` / `bg.cancel` |
115
115
  | `root-ops` | `whoami.run` | `root.run` of `root/whoami.sh`, once an administrator approves the executor |
116
116
  | `delegate` | `report.save` | `delegate.move_from_staging` into a folder the caller chose |
117
117
 
@@ -159,6 +159,12 @@ ticks **Also delete the app data listed above** in the uninstall wizard.
159
159
  | Target CPUs | x86_64 and aarch64 DSM models (one SPK each); armv7 is opt-in in `arches`, built but not NAS-verified |
160
160
  | DSM | 7.1 or later declared (`os_min_ver`) |
161
161
 
162
- The NAS file hashing example is temporarily disabled pending browser-owned
163
- input migration. NAS import submission returns `unsupported` without reading a
164
- file or creating a job; existing worker and job-control APIs remain available.
162
+ ## Background job input
163
+
164
+ Jobs process an existing file imported by the App's producer into its private
165
+ data directory. The submission params are `{key, name, input}`: an idempotency
166
+ key, a display name and an absolute path inside that directory. The SDK keeps a
167
+ bounded private copy for the worker; no browser session or NAS picker is used.
168
+ Submit with `hash.submit`; use `hash.get` for progress and `hash.cancel` to
169
+ cancel. These handlers call the existing `bg.submit`, `bg.get` and `bg.cancel`
170
+ services; the runtime checks the input path and binds ownership to the caller.
@@ -1,17 +1,11 @@
1
- // The NAS hashing example is disabled until browser-owned input is wired.
2
- // Keep caller-bound job controls; never relay a browser session from the backend.
3
- import { AppError, type App, type Context } from '@synopackageland/app'
1
+ // Background jobs over files already imported into the App data directory.
2
+ import type { App } from '@synopackageland/app'
4
3
  import { strings } from './params.js'
5
4
  import type { Config } from './routes.js'
6
5
  import { service } from './service.js'
7
6
 
8
- export function register(app: App, cfg: Config): void {
9
- app.method('hash.submit', (ctx, params) => submit(cfg, ctx, params))
7
+ export function register(app: App, _cfg: Config): void {
8
+ app.method('hash.submit', (ctx, params) => service(ctx, 'bg.submit', strings(params, 'key', 'name', 'input')))
10
9
  app.method('hash.get', (ctx, params) => service(ctx, 'bg.get', strings(params, 'id')))
11
10
  app.method('hash.cancel', (ctx, params) => service(ctx, 'bg.cancel', strings(params, 'id')))
12
11
  }
13
-
14
- async function submit(_cfg: Config, _ctx: Context, params: unknown) {
15
- strings(params, 'key', 'path')
16
- throw new AppError('unsupported', "The NAS file hashing example is disabled pending browser input migration. Existing job control APIs remain available.")
17
- }
@@ -17,14 +17,15 @@ beforeEach(() => {
17
17
  })
18
18
  afterEach(() => rmSync(root, { recursive: true, force: true }))
19
19
 
20
- test('the NAS hash example refuses without services or persistent writes', async () => {
21
- const reply = await call(build(cfg), 'hash.submit', { key: 'k1', path: '/home/report.txt' })
22
- assert.deepEqual([reply.error?.code, reply.services], ['unsupported', []])
20
+ test('hash.submit passes imported input to the runtime', async () => {
21
+ const params = { key: 'k1', name: 'report.txt', input: join(cfg.dataDir, 'report.txt') }
22
+ const reply = await call(build(cfg), 'hash.submit', params, { services: { 'bg.submit': () => JOB } })
23
+ assert.deepEqual([reply.data, reply.services], [JOB, [['bg.submit', params]]])
23
24
  assert.deepEqual(readdirSync(cfg.dataDir), [])
24
25
  })
25
26
 
26
27
  test('hash.submit refuses invalid params before any service', async () => {
27
- for (const params of [null, { key: 'k1' }, { key: 1, path: '/home/a' }, { key: 'k1', path: '/home/a', x: 1 }]) {
28
+ for (const params of [null, { key: 'k1' }, { key: 1, name: 'a', input: '/app/a' }, { key: 'k1', name: 'a', input: '/app/a', x: 1 }]) {
28
29
  const reply = await call(build(cfg), 'hash.submit', params)
29
30
  assert.deepEqual([reply.error?.code, reply.services], ['bad_request', []], JSON.stringify(params))
30
31
  }
@@ -108,7 +108,7 @@ rebuilding the SPK.
108
108
 
109
109
  | `--with` | Methods | Runtime services |
110
110
  | --- | --- | --- |
111
- | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | `hash.submit` is disabled pending browser input migration; `bg.get` and `bg.cancel` remain available |
111
+ | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | Submit an App-owned imported file with `{key, name, input}` via `bg.submit`; look up or cancel the caller's job via `bg.get` / `bg.cancel` |
112
112
  | `root-ops` | `whoami.run` | `root.run` of `root/whoami.sh`, once an administrator approves the executor |
113
113
  | `delegate` | `report.save` | `delegate.move_from_staging` into a folder the caller chose |
114
114
 
@@ -156,6 +156,12 @@ ticks **Also delete the app data listed above** in the uninstall wizard.
156
156
  | Target CPUs | x86_64 and aarch64 DSM models (one SPK each); armv7 is opt-in in `arches`, built but not NAS-verified |
157
157
  | DSM | 7.1 or later declared (`os_min_ver`) |
158
158
 
159
- The NAS file hashing example is temporarily disabled pending browser-owned
160
- input migration. NAS import submission returns `unsupported` without reading a
161
- file or creating a job; existing worker and job-control APIs remain available.
159
+ ## Background job input
160
+
161
+ Jobs process an existing file imported by the App's producer into its private
162
+ data directory. The submission params are `{key, name, input}`: an idempotency
163
+ key, a display name and an absolute path inside that directory. The SDK keeps a
164
+ bounded private copy for the worker; no browser session or NAS picker is used.
165
+ Submit with `hash.submit`; use `hash.get` for progress and `hash.cancel` to
166
+ cancel. These handlers call the existing `bg.submit`, `bg.get` and `bg.cancel`
167
+ services; the runtime checks the input path and binds ownership to the caller.
@@ -1,17 +1,8 @@
1
- """The NAS hashing example is disabled until browser-owned input is wired.
2
-
3
- Caller-bound job controls remain available; no backend session replay is used.
4
- """
5
- from synopkgland_app import Error
1
+ """Background jobs over files already imported into the App data directory."""
6
2
  from params import fields
7
3
 
8
4
 
9
5
  def register(app, cfg):
10
- app.method("hash.submit")(lambda ctx, params: submit(cfg, ctx, params))
6
+ app.method("hash.submit")(lambda ctx, params: ctx.runtime("bg.submit", fields(params, key=str, name=str, input=str)))
11
7
  app.method("hash.get")(lambda ctx, params: ctx.runtime("bg.get", fields(params, id=str)))
12
8
  app.method("hash.cancel")(lambda ctx, params: ctx.runtime("bg.cancel", fields(params, id=str)))
13
-
14
-
15
- def submit(cfg, ctx, params):
16
- fields(params, key=str, path=str)
17
- raise Error("unsupported", "The NAS file hashing example is disabled pending browser input migration. Existing job control APIs remain available.")
@@ -17,13 +17,14 @@ class JobsTest(unittest.TestCase):
17
17
  os.mkdir(self.data)
18
18
  self.app = routes.build(routes.Config("ExampleApp", self.data))
19
19
 
20
- def test_nas_hash_example_refuses_without_services_or_writes(self):
21
- reply = call(self.app, "hash.submit", {"key": "k1", "path": "/home/report.txt"})
22
- self.assertEqual((reply.error["code"], reply.services), ("unsupported", []))
20
+ def test_submit_passes_imported_input_to_the_runtime(self):
21
+ params = {"key": "k1", "name": "report.txt", "input": os.path.join(self.data, "report.txt")}
22
+ reply = call(self.app, "hash.submit", params, services={"bg.submit": lambda p: JOB})
23
+ self.assertEqual((reply.data, reply.services), (JOB, [("bg.submit", params)]))
23
24
  self.assertEqual(os.listdir(self.data), [])
24
25
 
25
26
  def test_submit_refuses_invalid_params_before_any_service(self):
26
- for params in [None, {"key": "k1"}, {"key": 1, "path": "/home/a"}, {"key": "k1", "path": "/home/a", "x": 1}]:
27
+ for params in [None, {"key": "k1"}, {"key": 1, "name": "a", "input": "/app/a"}, {"key": "k1", "name": "a", "input": "/app/a", "x": 1}]:
27
28
  with self.subTest(params=params):
28
29
  reply = call(self.app, "hash.submit", params)
29
30
  self.assertEqual((reply.error["code"], reply.services), ("bad_request", []))
@@ -117,7 +117,7 @@ rebuilding the SPK.
117
117
 
118
118
  | `--with` | Methods | Runtime services |
119
119
  | --- | --- | --- |
120
- | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | `hash.submit` is disabled pending browser input migration; `bg.get` and `bg.cancel` remain available |
120
+ | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | Submit an App-owned imported file with `{key, name, input}` via `bg.submit`; look up or cancel the caller's job via `bg.get` / `bg.cancel` |
121
121
  | `root-ops` | `whoami.run` | `root.run` of `root/whoami.sh`, once an administrator approves the executor |
122
122
  | `delegate` | `report.save` | `delegate.move_from_staging` into a folder the caller chose |
123
123
 
@@ -165,6 +165,12 @@ ticks **Also delete the app data listed above** in the uninstall wizard.
165
165
  | Target CPUs | x86_64 and aarch64 DSM models (one SPK each); armv7 is opt-in in `arches`, built but not NAS-verified |
166
166
  | DSM | 7.1 or later declared (`os_min_ver`) |
167
167
 
168
- The NAS file hashing example is temporarily disabled pending browser-owned
169
- input migration. NAS import submission returns `unsupported` without reading a
170
- file or creating a job; existing worker and job-control APIs remain available.
168
+ ## Background job input
169
+
170
+ Jobs process an existing file imported by the App's producer into its private
171
+ data directory. The submission params are `{key, name, input}`: an idempotency
172
+ key, a display name and an absolute path inside that directory. The SDK keeps a
173
+ bounded private copy for the worker; no browser session or NAS picker is used.
174
+ Submit with `hash.submit`; use `hash.get` for progress and `hash.cancel` to
175
+ cancel. These handlers call the existing `bg.submit`, `bg.get` and `bg.cancel`
176
+ services; the runtime checks the input path and binds ownership to the caller.
@@ -1,4 +1,4 @@
1
- //! NAS hashing is disabled until browser-owned input is wired; job controls remain.
1
+ //! Background jobs over files already imported into the App data directory.
2
2
  use crate::routes::Config;
3
3
  use serde::Deserialize;
4
4
  use serde_json::{Value, json};
@@ -8,7 +8,8 @@ use syno_pkg_land_app::{App, Context, Error, Params};
8
8
  #[serde(deny_unknown_fields)]
9
9
  struct Submit {
10
10
  key: String,
11
- path: String,
11
+ name: String,
12
+ input: String,
12
13
  }
13
14
  #[derive(Deserialize)]
14
15
  #[serde(deny_unknown_fields)]
@@ -17,11 +18,13 @@ struct JobId {
17
18
  }
18
19
 
19
20
  pub fn register(app: &mut App, _cfg: &Config) {
20
- app.method("hash.submit", |_ctx, params| -> Result<Value, Error> {
21
- let Submit { key, path } = params.decode()?;
22
- let _ = (key, path);
23
- Err(Error::new("unsupported", "The NAS file hashing example is disabled pending browser input migration. Existing job control APIs remain available."))
24
- });
21
+ app.method("hash.submit", |ctx, params| -> Result<Value, Error> {
22
+ let Submit { key, name, input } = params.decode()?;
23
+ ctx.runtime(
24
+ "bg.submit",
25
+ &json!({"key": key, "name": name, "input": input}),
26
+ )
27
+ });
25
28
  app.method("hash.get", |ctx, params| job(ctx, "bg.get", params));
26
29
  app.method("hash.cancel", |ctx, params| job(ctx, "bg.cancel", params));
27
30
  }
@@ -49,17 +52,20 @@ mod tests {
49
52
  }
50
53
 
51
54
  #[test]
52
- fn nas_hash_example_refuses_without_services_or_writes() {
55
+ fn submit_passes_imported_input_to_the_runtime() {
53
56
  let (_dir, cfg) = setup();
57
+ let params =
58
+ json!({"key":"k1","name":"report.txt","input":cfg.data_dir.join("report.txt")});
59
+ let services: Services = [("bg.submit", Box::new(|_: &Value| Ok(job())) as _)].into();
54
60
  let reply = call(
55
- crate::routes::build(cfg.clone()),
61
+ crate::routes::build(cfg),
56
62
  "hash.submit",
57
- json!({"key":"k1","path":"/home/report.txt"}),
63
+ params.clone(),
58
64
  "alice",
59
- &Services::new(),
65
+ &services,
60
66
  );
61
- assert_eq!((reply.code(), reply.services.len()), ("unsupported", 0));
62
- assert_eq!(std::fs::read_dir(&cfg.data_dir).unwrap().count(), 0);
67
+ assert_eq!(reply.data, job());
68
+ assert_eq!(reply.services, vec![("bg.submit".to_string(), params)]);
63
69
  }
64
70
  #[test]
65
71
  fn submit_refuses_invalid_params_before_any_service() {
@@ -67,8 +73,8 @@ mod tests {
67
73
  for params in [
68
74
  Value::Null,
69
75
  json!({"key": "k1"}),
70
- json!({"key": 1, "path": "/home/a"}),
71
- json!({"key": "k1", "path": "/home/a", "x": 1}),
76
+ json!({"key": 1, "name": "a", "input": "/app/a"}),
77
+ json!({"key": "k1", "name": "a", "input": "/app/a", "x": 1}),
72
78
  ] {
73
79
  let reply = call(
74
80
  crate::routes::build(cfg.clone()),