@synopackageland/templates 0.0.0-stage → 0.9.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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -2
  3. package/package.json +11 -4
  4. package/templates/_shared/AGENTS.md +149 -0
  5. package/templates/_shared/CLAUDE.md +1 -0
  6. package/templates/_shared/GO-LICENSE +27 -0
  7. package/templates/_shared/GO-PATENTS +22 -0
  8. package/templates/_shared/LICENSES/TEMPLATE.txt +21 -0
  9. package/templates/_shared/third-party-notices.mjs +55 -0
  10. package/templates/binary/LICENSES/GO-LICENSE +27 -0
  11. package/templates/binary/LICENSES/GO-PATENTS +22 -0
  12. package/templates/binary/dsm-package.yaml +46 -0
  13. package/templates/binary/example/main.go +16 -0
  14. package/templates/binary/gitignore +2 -0
  15. package/templates/binary/i18n/messages/de.json +4 -0
  16. package/templates/binary/i18n/messages/en.json +4 -0
  17. package/templates/binary/i18n/messages/es.json +4 -0
  18. package/templates/binary/i18n/messages/fr.json +4 -0
  19. package/templates/binary/i18n/messages/it.json +4 -0
  20. package/templates/binary/i18n/messages/ja.json +4 -0
  21. package/templates/binary/i18n/messages/ko.json +4 -0
  22. package/templates/binary/i18n/messages/zh-CN.json +4 -0
  23. package/templates/binary/i18n/messages/zh-TW.json +4 -0
  24. package/templates/binary/ui/app.css +4 -0
  25. package/templates/binary/ui/app.js +32 -0
  26. package/templates/compose/compose.yaml +8 -0
  27. package/templates/compose/synology-app.yaml +16 -0
  28. package/templates/native-go/README.md +372 -0
  29. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +53 -0
  30. package/templates/native-go/backend/cmd/app/main.go +101 -0
  31. package/templates/native-go/backend/cmd/background-worker/main.go +38 -0
  32. package/templates/native-go/backend/go.mod +9 -0
  33. package/templates/native-go/backend/go.sum +4 -0
  34. package/templates/native-go/backend/internal/api/permissions.go +26 -0
  35. package/templates/native-go/backend/internal/api/permissions.json +5 -0
  36. package/templates/native-go/backend/internal/api/permissions_test.go +30 -0
  37. package/templates/native-go/backend/internal/app/background.go +249 -0
  38. package/templates/native-go/backend/internal/app/background_none.go +8 -0
  39. package/templates/native-go/backend/internal/app/background_owner.go +38 -0
  40. package/templates/native-go/backend/internal/app/background_owner_test.go +163 -0
  41. package/templates/native-go/backend/internal/app/background_schedule.go +69 -0
  42. package/templates/native-go/backend/internal/app/background_schedule_test.go +62 -0
  43. package/templates/native-go/backend/internal/app/background_test.go +320 -0
  44. package/templates/native-go/backend/internal/app/delegate_config.go +57 -0
  45. package/templates/native-go/backend/internal/app/delegate_config_test.go +36 -0
  46. package/templates/native-go/backend/internal/app/delegate_none.go +5 -0
  47. package/templates/native-go/backend/internal/app/identity.go +20 -0
  48. package/templates/native-go/backend/internal/app/identity_test.go +15 -0
  49. package/templates/native-go/backend/internal/app/migrations.go +41 -0
  50. package/templates/native-go/backend/internal/app/migrations_test.go +41 -0
  51. package/templates/native-go/backend/internal/app/portal.go +30 -0
  52. package/templates/native-go/backend/internal/app/portal_test.go +55 -0
  53. package/templates/native-go/backend/internal/app/root_none.go +9 -0
  54. package/templates/native-go/backend/internal/app/routes.go +137 -0
  55. package/templates/native-go/backend/internal/app/routes_test.go +207 -0
  56. package/templates/native-go/backend/internal/app/session_files.go +9 -0
  57. package/templates/native-go/backend/internal/app/session_files_test.go +14 -0
  58. package/templates/native-go/backend/internal/app/shared_folder.go +144 -0
  59. package/templates/native-go/backend/internal/app/shared_folder_test.go +158 -0
  60. package/templates/native-go/dsm-package.yaml +71 -0
  61. package/templates/native-go/gitignore +1 -0
  62. package/templates/native-go/i18n/glossary.json +663 -0
  63. package/templates/native-go/i18n/messages/de.json +29 -0
  64. package/templates/native-go/i18n/messages/en.json +29 -0
  65. package/templates/native-go/i18n/messages/es.json +29 -0
  66. package/templates/native-go/i18n/messages/fr.json +29 -0
  67. package/templates/native-go/i18n/messages/it.json +29 -0
  68. package/templates/native-go/i18n/messages/ja.json +29 -0
  69. package/templates/native-go/i18n/messages/ko.json +29 -0
  70. package/templates/native-go/i18n/messages/zh-CN.json +29 -0
  71. package/templates/native-go/i18n/messages/zh-TW.json +29 -0
  72. package/templates/native-go/ui/app.css +45 -0
  73. package/templates/native-go/ui/app.js +111 -0
  74. package/templates/native-go-delegate/backend/cmd/background-worker/main.go +49 -0
  75. package/templates/native-go-delegate/backend/internal/app/delegate.go +25 -0
  76. package/templates/native-go-delegate/backend/internal/app/delegate_on.go +5 -0
  77. package/templates/native-go-delegate/backend/internal/app/delegate_routes_test.go +31 -0
  78. package/templates/native-go-delegate/frontend/src/CapabilityPanels.vue +32 -0
  79. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +77 -0
  80. package/templates/native-go-jobs/backend/internal/app/background_on.go +7 -0
  81. package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +87 -0
  82. package/templates/native-go-jobs/frontend/test/background-page.test.ts +20 -0
  83. package/templates/native-go-root-ops/backend/internal/app/executor.go +128 -0
  84. package/templates/native-go-root-ops/backend/internal/app/executor_test.go +237 -0
  85. package/templates/native-go-root-ops/root/whoami.sh +3 -0
  86. package/templates/native-go-vue/README.md +51 -0
  87. package/templates/native-go-vue/frontend/DESIGN.md +61 -0
  88. package/templates/native-go-vue/frontend/package-lock.json +2550 -0
  89. package/templates/native-go-vue/frontend/package.json +23 -0
  90. package/templates/native-go-vue/frontend/src/App.vue +90 -0
  91. package/templates/native-go-vue/frontend/src/CapabilityPanels.vue +6 -0
  92. package/templates/native-go-vue/frontend/src/i18n/de.json +20 -0
  93. package/templates/native-go-vue/frontend/src/i18n/en.json +20 -0
  94. package/templates/native-go-vue/frontend/src/i18n/es.json +20 -0
  95. package/templates/native-go-vue/frontend/src/i18n/fr.json +20 -0
  96. package/templates/native-go-vue/frontend/src/i18n/it.json +20 -0
  97. package/templates/native-go-vue/frontend/src/i18n/ja.json +20 -0
  98. package/templates/native-go-vue/frontend/src/i18n/ko.json +20 -0
  99. package/templates/native-go-vue/frontend/src/i18n/zh-CN.json +20 -0
  100. package/templates/native-go-vue/frontend/src/i18n/zh-TW.json +20 -0
  101. package/templates/native-go-vue/frontend/src/i18n.ts +13 -0
  102. package/templates/native-go-vue/frontend/src/main.ts +33 -0
  103. package/templates/native-go-vue/frontend/src/style.css +27 -0
  104. package/templates/native-go-vue/frontend/test/apps.test.ts +57 -0
  105. package/templates/native-go-vue/frontend/test/native-controls.test.ts +27 -0
  106. package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +50 -0
  107. package/templates/native-go-vue/frontend/tsconfig.json +1 -0
  108. package/templates/native-go-vue/frontend/vite.config.ts +11 -0
  109. package/templates/native-go-vue/gitignore +2 -0
@@ -0,0 +1,372 @@
1
+ # __DISPLAY_NAME__
2
+
3
+ A native DSM package (`__PACKAGE__`): a Go backend that DSM runs as a CGI,
4
+ and a UI that opens in its own DSM window. It installs as an **unsigned** SPK,
5
+ runs as its own DSM package account, needs no containers, and has no root by default.
6
+
7
+ ```text
8
+ DSM main menu → DSM window (ui/app.js)
9
+ │ ctx.call("greeting.get") same-origin POST + DSM session
10
+ ▼
11
+ /webman/3rdparty/__PACKAGE__/cgi/api.cgi (backend/, runs as package user)
12
+ 1. transport guards: POST, JSON, same origin, ≤ 1 MiB, bounded JSON complexity
13
+ 2. one DSM authentication (unless the route is Public)
14
+ 3. route policy: User or Admin → refusal before any handler
15
+ 4. your handler (backend/internal/app)
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ Needs Node.js with `synopkgland` installed, and Go 1.25+.
21
+
22
+ ```bash
23
+ (cd backend && go test ./...) # auth policy + route tests
24
+ synopkgland build # → dist/__PACKAGE__-0.1.0-0001-{x86_64,aarch64}.spk
25
+ synopkgland inspect dist/__PACKAGE__-0.1.0-0001-x86_64.spk
26
+ ```
27
+
28
+ Install: Package Center → Settings → General → Trust Level "Any publisher",
29
+ then Manual Install with the SPK for your NAS's CPU. Open
30
+ **__DISPLAY_NAME__** from the DSM main menu: it shows who you are signed in
31
+ as, what the backend reports, and (for administrators) a form that stores a
32
+ greeting in the package's data folder.
33
+
34
+ ## Capabilities and schema 2
35
+
36
+ `dsm-package.yaml` requires `schema: 2` and `backend: {kind: go, dir: backend}`.
37
+ Declare `capabilities.files: session` for interactive File Station access,
38
+ `capabilities.jobs: true` for persistent package-account jobs,
39
+ `capabilities.identity: standalone` for independent DSM login, and
40
+ `capabilities.rootOps: true` for administrator-approved fixed executor operations.
41
+ `init --with files,jobs,root-ops` supports plain and Vue UI; `login` and
42
+ `delegate` need the Vue UI. `daemon` cannot be combined with `jobs`, but it can
43
+ be combined with an until-revoked `delegate`.
44
+
45
+ ```sh
46
+ synopkgland add files jobs login
47
+ synopkgland check --json
48
+ synopkgland status --host my-nas --json
49
+ ```
50
+
51
+ `add` writes SDK overlay files and updates capabilities. Your handlers/pages and
52
+ application entry files remain app-owned. Differing existing overlay files and
53
+ modified SDK placeholders cause `ADD_FILE_CONFLICT` with the conflicting paths;
54
+ resolve them before retrying. Already-declared capabilities are a no-op.
55
+ `check`, `build`, `inspect`, `deploy`, `status` emit one stdout object with
56
+ `--json`; failures include `code`, `message`, `hint`.
57
+
58
+ ## Project layout
59
+
60
+ | Path | What it is | Edit it? |
61
+ | --- | --- | --- |
62
+ | `dsm-package.yaml` | Package identity, version/build, arches, where backend and UI come from | Yes |
63
+ | `ui/app.js`, `ui/app.css` | The app inside the DSM window | Yes — your UI |
64
+ | `ui/…` | Any other assets; served from `/webman/3rdparty/__PACKAGE__/app/` | Yes |
65
+ | `i18n/messages/<locale>.json` | UI text in nine languages | Yes |
66
+ | `i18n/glossary.json` | DSM's own UI terms, and which messages must use them | Add terms |
67
+ | `backend/internal/app/` | Your API: routes and handlers | Yes — your backend |
68
+ | `backend/internal/api/permissions.json` | Who may call each of your methods | Yes |
69
+ | `backend/cmd/app/` | Wires your routes to the SDK server | Rarely |
70
+ | `backend/go.mod` | Your module; `require github.com/synopackageland/syno-pkg-land-sdk` pins the SDK | SDK upgrades |
71
+ | `dist/` | Built SPKs (ignored by git) | No |
72
+
73
+ `build` generates everything else: `INFO`, `conf/privilege`
74
+ (`run-as: package`), no-op lifecycle scripts, icons, the DSM window wrapper
75
+ (`ui/config`, `ui/window-<build>.js`) and the CGI at `ui/cgi/api.cgi`.
76
+
77
+ ## Add an API operation
78
+
79
+ Add a handler in `backend/internal/app/routes.go`. Every method defaults to
80
+ DSM administrator membership, resolved by its native SDK. All access rules live in
81
+ `backend/internal/api/permissions.json`, including launcher access:
82
+
83
+ ```go
84
+ func Routes(cfg Config) []api.Route {
85
+ return []api.Route{
86
+ api.Handle("app.status", cfg.status),
87
+ api.Handle("greeting.get", cfg.getGreeting),
88
+ api.Handle("greeting.set", cfg.setGreeting),
89
+ api.Handle("notes.list", cfg.listNotes), // new: administrators by default
90
+ api.Handle("notes.purge", cfg.purgeNotes), // new: administrators by default
91
+ }
92
+ }
93
+
94
+ func (cfg Config) listNotes(ctx context.Context, caller api.Caller, params json.RawMessage) (any, error) {
95
+ var in struct{ Limit int `json:"limit"` }
96
+ if err := decodeParams(params, &in); err != nil {
97
+ return nil, err
98
+ }
99
+ return map[string]any{"owner": caller.User(), "notes": []string{}}, nil
100
+ }
101
+ ```
102
+
103
+ Then add the method's valid params to `sampleParams` in `routes_test.go`; the
104
+ test fails until you do, and once you do it proves the new route refuses
105
+ callers without a session and ordinary users without touching
106
+ the data folder.
107
+
108
+ Call it from the UI with `ctx.call("notes.list", { limit: 20 })`. Errors
109
+ reject with `{ code, message }`, for example `session_required`,
110
+ `admin_required`, `bad_request` or `internal`.
111
+
112
+ ### Who can call what
113
+
114
+ The shipped policy is:
115
+
116
+ ```json
117
+ {"schema": 1, "default": "administrators", "methods": {}}
118
+ ```
119
+
120
+ The default must remain `administrators`. An intentional exception belongs in
121
+ `methods`, for example `"notes.list": "authenticated"`; `"public"` explicitly
122
+ allows anonymous calls. Such exceptions also enable ordinary-user launcher
123
+ access. The template ships no exceptions and sets DSM `allUsers` to `false`.
124
+
125
+ | JSON rule | No session | Ordinary user | Administrators group member |
126
+ | --- | --- | --- | --- |
127
+ | absent or `administrators` | `session_required` | `admin_required` | runs |
128
+ | explicit `authenticated` | `session_required` | runs | runs |
129
+ | explicit `public` | runs | runs | runs |
130
+ | unregistered method | `session_required` | `unknown_method` | `unknown_method` |
131
+
132
+ Missing or invalid JSON, duplicate keys, unknown permissions and rules for
133
+ unregistered methods fail closed. The CLI validates the file and binds its
134
+ SHA-256 digest to the compiled CGI, which checks it at startup before migration.
135
+ Changing policy requires rebuilding the SPK; request parameters cannot alter it.
136
+ Register routes with `api.Handle`. Binary reads cannot be configured as anonymous.
137
+
138
+ ## The SDK
139
+
140
+ Everything outside `backend/internal/app`, `backend/internal/api/permissions.json`
141
+ and your UI comes from the SDK: the Go module `github.com/synopackageland/syno-pkg-land-sdk`
142
+ (pinned in `backend/go.mod`, the single source of the SDK version) and, for the
143
+ Vue UI, the npm package `@synopackageland/sdk` at the same version. Transport
144
+ guards, DSM authentication, the administrator check, File Station, jobs,
145
+ identity, portal, migrations and the executor protocol are documented in the SDK
146
+ repository: <https://github.com/synopackageland/syno-pkg-land-sdk>.
147
+
148
+ Upgrade the SDK by changing the `require` line (and `frontend/package.json`) to
149
+ the new version; `synopkgland check` refuses mismatched versions.
150
+
151
+ ## Customize the UI
152
+
153
+ `ui/app.js` must register `window.DsmPackageApps[<package>].mount(root, ctx)`
154
+ (the template shows how, using `document.currentScript.dataset.package`).
155
+ `ctx.call(method, params)` is the only transport. `ctx.reopen(fn)` runs `fn`
156
+ whenever DSM shows the window again and returns an idempotent unsubscribe function. Scope your CSS under your own root class:
157
+ every DSM app shares one page.
158
+
159
+ Plain JS and Vue receive the same `ctx.host` adapter. `ctx.host.platform()`
160
+ returns a fresh frozen snapshot: `web`, `host` (`dsm`, `standalone-web` or
161
+ `unavailable`), `dsmStandalone`, `dsmLanguage`, `appLocale`, `systemVersion`,
162
+ `fullVersion`, and `theme`. Unknown fields are `null`; `theme` stays unknown.
163
+ DSM launch mode is separate from plain-web fallback, and `appLocale` is the
164
+ app's catalog locale, which may differ from DSM's UI language code.
165
+
166
+ ```js
167
+ const platform = ctx.host.platform();
168
+ const result = ctx.host.setTitle('My notes');
169
+ if (result.status !== 'ok') showTitleStatus(result.status);
170
+ ```
171
+
172
+ `setTitle` enables DSM HTML encoding or assigns standalone `document.title`.
173
+ It returns `{status: 'ok', target: 'dsm-window' | 'document'}` on synchronous
174
+ completion, or `unsupported`, `unavailable`, `invalid-input`, `failed` statuses.
175
+ A missing DSM method is unsupported; it never changes the DSM desktop's title.
176
+ Destroy disposes the adapter and clears reopen handlers; retained adapters
177
+ return unavailable. Navigation exposes activateWindow/requestClose/openApp/openURL;
178
+ requests do not acknowledge completion. `ctx.host.subscribe('window-resize', fn)`
179
+ returns `{status: 'subscribed', dispose}` only for a verified DSM window with on/un.
180
+ The callback receives no native arguments. Remove your subscriptions on unmount;
181
+ wrapper destroy also cleans them. `host-theme-change` and `host-language-change`
182
+ return unsupported: no third-party host event is proven. These observations do
183
+ not authorize work.
184
+
185
+ `ctx.host.openFileManager('/example-share/docs')` requests File Station folder
186
+ navigation; `revealFile('/home/report.txt')` requests a file highlight. Both require
187
+ a registered, enabled and launchable File Station and report requested plus action,
188
+ not location completion or ACL success. Use canonical virtual share paths only;
189
+ OS `/volumeN/...`, traversal, repeated/trailing slash, controls and backslash are
190
+ invalid. No path conversion or grant is performed. `openFile`, `showFileDetails`
191
+ and `openAppSetting` return unsupported; private preview/details/settings flows
192
+ have no proven generic contract.
193
+
194
+ File reveal adds a best-effort correction using File Station 1.4.x private
195
+ structures. Required APIs are feature-detected; absent/throwing APIs silently
196
+ cancel and release timers/listeners. One AppLaunch callback binds one receiver;
197
+ only its model may be selected once. Interaction, navigation away from the reached
198
+ parent, close/destroy, disposal and new requests cancel the correction. The named
199
+ 5000 ms total budget, 100 ms checks and 300 ms quiet interval have no device latency
200
+ proof. `requested` still does not mean `selected`; actual effectiveness on DSM 7.4 /
201
+ File Station 1.4.4 awaits a rebuilt-SPK device matrix. No second launch or broadcast
202
+ setter is used, and later reloads may still clear selection.
203
+
204
+ For an independent browser shell, use the SPK's generated `ui/host-0001.js`
205
+ (the number follows the manifest build). It has no Ext or npm runtime dependency:
206
+
207
+ ```html
208
+ <script src="./host-0001.js"></script>
209
+ ```
210
+
211
+ ```js
212
+ // shellContext supplies your app's own auth/transport and other context members.
213
+ const ctx = {...shellContext, host: window.DsmPackageHosts.__PACKAGE__({locale: 'en'})};
214
+ const app = window.DsmPackageApps.__PACKAGE__.mount(root, ctx);
215
+ // When your shell unmounts:
216
+ ctx.host.dispose();
217
+ app.unmount();
218
+ ```
219
+
220
+ Serve the generated script with your shell (or reference its same-origin DSM
221
+ static path). `ctx.host` does not create a standalone backend, DSM session,
222
+ cross-origin iframe bridge, or implementations of `ctx.call/t/root/reopen`.
223
+ Standalone title affects the document shared by that shell's mounts.
224
+
225
+ To build the UI with Vite, Vue or React, point `ui` at the build output and
226
+ give the build command as an argv list (no shell):
227
+
228
+ ```yaml
229
+ ui:
230
+ dir: web/dist
231
+ build:
232
+ cwd: web
233
+ command: [npm, run, build]
234
+ ```
235
+
236
+ The build must emit `app.js` (one script that registers `mount`) and
237
+ `app.css`. The installed `@synopackageland/ui` package gives Vue components with DSM 7 styling;
238
+ import its `themes/dsm7-embedded.css` for use inside a DSM window.
239
+
240
+ ## Languages
241
+
242
+ The UI ships in nine of DSM's languages: `en`, `zh-TW`,
243
+ `zh-CN`, `ja`, `ko`, `de`, `fr`, `es` and `it`. DSM's other languages show
244
+ English. The window follows DSM's own language setting (`_S("lang")`).
245
+
246
+ - **Messages**: `i18n/messages/<locale>.json`, flat `"key": "text"`. Use
247
+ `ctx.t("greeting.setBy", { text, user })` in the UI; `{name}` is a
248
+ placeholder, and `ctx.t("files", { count: 3 })` picks `files.one` or
249
+ `files.other`. Missing keys fall back to English, then to the key itself.
250
+ `build` embeds the catalogs in the DSM window script, so there is no
251
+ extra request.
252
+ - **Errors**: the backend returns a `code`; the UI shows
253
+ `error.<code>` from the catalogs and uses the backend's English text only for
254
+ codes it has no message for. Add `error.<code>` for every code you return.
255
+ - **Glossary**: `i18n/glossary.json` records DSM's own words in all nine
256
+ languages, each with its source (`section/key` in DSM 7.4's
257
+ `/usr/syno/synoman/webman/texts/<lang>/strings`). A term's `keys` lists the
258
+ messages that must contain it; that is how "Save" uses DSM's own word in
259
+ every language, the same as the rest of DSM. Product names in `untranslated`
260
+ stay in English everywhere.
261
+ - **Package Center text**: `i18n.info` in `dsm-package.yaml` writes INFO
262
+ `displayname_<lang>` / `description_<lang>` (for example `description_cht`).
263
+
264
+ `synopkgland check` and `build` refuse a language file with missing or
265
+ extra keys, a translation that drops a `{placeholder}`, an unsupported
266
+ language, or a listed message that drifts from the glossary.
267
+
268
+ To add a message: add the key to `en.json` and to the other eight files. To
269
+ use a DSM term, copy its `text` from the glossary and add your key to the
270
+ term's `keys`. A Vue or React UI can keep its own i18n library; `ctx.t` and
271
+ `ctx.locale` are there if you want them.
272
+
273
+ The Vue shell (`--ui vue`) keeps the app's own text in `frontend/src/i18n/<locale>.json`,
274
+ in the same nine languages; SDK components (sign-in, files, background jobs) ship
275
+ their own catalogs. Add new text to all nine files rather than writing it into a
276
+ `.vue` file.
277
+
278
+ ## Package an existing binary
279
+
280
+ Keep the Go backend and call your tool from a handler (recommended: auth
281
+ stays central), or replace the CGI with your own executable:
282
+
283
+ ```yaml
284
+ arches: [x86_64]
285
+ backend:
286
+ kind: binary
287
+ files:
288
+ x86_64: prebuilt/api-x86_64
289
+ ```
290
+
291
+ The file must be a regular file in the project, a 64-bit Linux ELF for that
292
+ arch, and statically linked (DSM models ship different libc versions). It is
293
+ installed as `ui/cgi/api.cgi` (mode 0755) and runs as the package account.
294
+ It then owns everything the SDK server does for you: bounded reads,
295
+ POST-only JSON, same-origin checks, authentication before any work, and a
296
+ refusal for unknown methods.
297
+
298
+ ## DSM Application Portal link
299
+
300
+ Only an App with the root executor (`rootOps` or `delegate`) can declare a
301
+ portal; `synopkgland add root-ops portal` declares both. Uninstall then
302
+ removes the App's Application Privileges rules and reloads nginx once DSM has
303
+ removed the alias, so it stops answering. `synopkgland add portal` adds
304
+ `portal: {}` to dsm-package.yaml so
305
+ `https://<nas>/<alias>/` opens this app right after DSM sign-in. The install
306
+ wizard proposes the alias (the package name in lowercase by default); an
307
+ administrator turns it on from the app's portal panel or Control Panel >
308
+ Login Portal > Applications. Ordinary users then see the link; until then
309
+ they see "ask an administrator". DSM owns the setting: the backend's
310
+ `portal.status` only reads it, and nothing in the package may write it. See
311
+ `synopkgland skill show portal`.
312
+
313
+ A portal needs DSM 7.3.2-86009 or later, so `add portal` raises a lower
314
+ `os_min_ver` and build refuses one below it. DSM 7.1.1 rejects an unsigned
315
+ package that registers a portal (Package Center error 320); 7.3.2 and 7.4
316
+ install it; 7.2 is untested. To keep supporting older DSM, remove `portal:`.
317
+
318
+ ## Data, upgrades, uninstall
319
+
320
+ The backend stores data in `/var/packages/__PACKAGE__/var` (the package's
321
+ own folder, kept across upgrades). Bump `build` for every SPK you hand out;
322
+ bump `version` for feature releases. There are no lifecycle scripts to write:
323
+ the CGI starts per request, so start/stop do nothing.
324
+
325
+ ### What an upgrade keeps
326
+
327
+ An upgrade replaces the program files and keeps the data. DSM preserves `var`
328
+ (`@appdata`), `home` (`@apphome`) and `etc` (`@appconf`) across an upgrade and
329
+ removes `tmp` (`@apptemp`) and `target` (`@appstore`). Keep everything you need
330
+ to keep under `var`, `home` or `etc`.
331
+
332
+ ### Schema migrations
333
+
334
+ The SDK's `datamigrate` package applies ordered, idempotent schema migrations and
335
+ records the result in `<var>/data-version`. `backend/cmd/app` runs `app.Migrate`
336
+ on every start; the recorded version makes that a cheap no-op.
337
+
338
+ - Versions start at 1 and never change meaning. The template ships one empty
339
+ example in `backend/internal/app/migrations.go`; replace it with your steps.
340
+ - Every step must be idempotent: a crash between `Apply` and the version record,
341
+ or two CGI requests racing on the first start after an upgrade, can run one step
342
+ twice.
343
+ - A step that fails stops the run with the previous version still recorded, and
344
+ the next start resumes from there. The helper never deletes or rewrites data
345
+ itself, so a failed migration leaves your old data in place.
346
+ - `datamigrate.Context.OldPkgVer` is the version being upgraded from:
347
+ `SYNOPKG_OLD_PKGVER` when your environment sets it, otherwise the version the last
348
+ successful run recorded. `NewPkgVer` is the version running now.
349
+
350
+ Uninstalling keeps your data: DSM leaves `@appdata` and `@apphome` on the volume,
351
+ and reinstalling the same package ID picks them up again. The install wizard is a
352
+ dynamic `WIZARD_UIFILES/install_uifile.sh` that checks — read-only — whether
353
+ `@appdata/<package>` already exists and, if it does, tells the administrator the
354
+ install reuses the old data and how to start clean.
355
+
356
+ The generated SPK ships
357
+ an uninstall wizard (English, plus eight other languages) with one box, **Also
358
+ delete the app data listed above** (`pkgwizard_remove_additional`), unchecked by
359
+ default. Ticked, `postuninst` empties `@appdata/<package>`, `@apphome/<package>`
360
+ and `@appconf/<package>` and nothing else: not Shared Folders, the package
361
+ account or other packages. An upgrade never deletes anything. Do not write your own `postuninst`;
362
+ `synopkgland inspect` flags a deleting script that lacks the upgrade guard
363
+ or deletes outside the package's own folders.
364
+
365
+ ## Support matrix
366
+
367
+ | | Status |
368
+ | --- | --- |
369
+ | Build hosts | macOS, Linux (any OS with Go and Node) |
370
+ | Target CPUs | x86_64 and aarch64 DSM models (one SPK each). armv7 (DSM 7.2+ platforms `alpine`, `alpine4k`, `armada38x`, `monaco`) is opt-in: add it to `arches`; it covers the Go CGI (plain or Vue UI), jobs, daemon, root-ops and delegate. armv7 jobs, daemon and executor (root-ops, delegate) are built and checked locally but have not been verified on a NAS |
371
+ | 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. |
372
+ | 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 |
@@ -0,0 +1,53 @@
1
+ Native Go package build dependencies
2
+
3
+ github.com/synopackageland/syno-pkg-land-sdk v0.9.1 (MIT)
4
+
5
+ Copyright (c) 2026 synopkgland
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
24
+
25
+ golang.org/x/sys v0.44.0 (BSD-3-Clause)
26
+
27
+ Copyright 2009 The Go Authors.
28
+
29
+ Redistribution and use in source and binary forms, with or without
30
+ modification, are permitted provided that the following conditions are
31
+ met:
32
+
33
+ * Redistributions of source code must retain the above copyright
34
+ notice, this list of conditions and the following disclaimer.
35
+ * Redistributions in binary form must reproduce the above
36
+ copyright notice, this list of conditions and the following disclaimer
37
+ in the documentation and/or other materials provided with the
38
+ distribution.
39
+ * Neither the name of Google LLC nor the names of its
40
+ contributors may be used to endorse or promote products derived from
41
+ this software without specific prior written permission.
42
+
43
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
44
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
45
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
46
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
47
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
48
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
49
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
50
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
51
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
52
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
53
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,101 @@
1
+ // Command app is the package backend. DSM runs it as a CGI (ui/cgi/api.cgi in
2
+ // the package) under the package account, once per request.
3
+ //
4
+ //line __PACKAGE__/cmd/app/main.go:1
5
+ package main
6
+
7
+ import (
8
+ "fmt"
9
+ "log"
10
+ "net/http"
11
+ "net/http/cgi"
12
+ "os"
13
+
14
+ "github.com/synopackageland/syno-pkg-land-sdk/api"
15
+ "github.com/synopackageland/syno-pkg-land-sdk/dsm"
16
+ "github.com/synopackageland/syno-pkg-land-sdk/filestation"
17
+ standaloneidentity "github.com/synopackageland/syno-pkg-land-sdk/identity"
18
+
19
+ appapi "__PACKAGE__/internal/api"
20
+ "__PACKAGE__/internal/app"
21
+ )
22
+
23
+ // Set by `synopkgland build-spk` through -ldflags -X; never read from a request.
24
+ var (
25
+ delegateScope = ""
26
+ delegateOperationsJSON = ""
27
+ delegateDestinationsJSON = ""
28
+ delegateLifetime = ""
29
+ executorDigest = ""
30
+ policyDigest = ""
31
+ packageID = "dev"
32
+ version = "0.0.0"
33
+ // "session" when the project opted into the interactive File Station adapter.
34
+ fileStation = ""
35
+ // "standalone" enables the SDK identity adapter; the runtime owns authentication.
36
+ identity = ""
37
+ // dsm-package.yaml entry: "admin-only" or "app-privilege"; empty keeps the route policy alone.
38
+ entry = ""
39
+ // "on" when dsm-package.yaml declares portal: serves portal.status.
40
+ portal = ""
41
+ )
42
+
43
+ func main() {
44
+ log.SetFlags(0)
45
+ log.SetPrefix(packageID + ": ")
46
+ if os.Getenv("GATEWAY_INTERFACE") == "" {
47
+ fmt.Fprintf(os.Stderr, "%s %s: this program runs as the package's DSM CGI; open the app from the DSM main menu.\n", packageID, version)
48
+ os.Exit(2)
49
+ }
50
+ cfg := app.Config{
51
+ DelegateScope: delegateScope,
52
+ DelegateOperationsJSON: delegateOperationsJSON,
53
+ DelegateDestinationsJSON: delegateDestinationsJSON,
54
+ DelegateLifetime: delegateLifetime,
55
+ ExecutorDigest: executorDigest, PolicyDigest: policyDigest,
56
+ PackageID: packageID,
57
+ Version: version,
58
+ DataDir: "/var/packages/" + packageID + "/var",
59
+ Portal: portal == "on",
60
+ }
61
+ if _, err := app.ParseDelegateConfig(cfg); err != nil {
62
+ log.Fatal(err)
63
+ }
64
+ if app.SessionFilesEnabled(fileStation) {
65
+ cfg.SessionFiles = filestation.Local(cfg.DataDir)
66
+ }
67
+ // Kernels without memfd_create stage DSM's bridges in the package's own temp.
68
+ dsm.SetBridgeFallbackDir("/var/packages/" + packageID + "/tmp")
69
+ var auth api.Authenticator = dsm.NewAuthenticator()
70
+ var standalone *standaloneidentity.Service
71
+ if identity == "standalone" {
72
+ var err error
73
+ standalone, err = app.StandaloneIdentity(cfg, os.Getenv("SCRIPT_NAME"))
74
+ if err != nil {
75
+ log.Fatal("identity configuration unavailable")
76
+ }
77
+ auth = standalone
78
+ }
79
+ auth, err := dsm.NewEntryGate(auth, entry, packageID, cfg.DataDir)
80
+ if err != nil {
81
+ log.Fatal(err)
82
+ }
83
+ permissions, err := appapi.Permissions()
84
+ if err != nil {
85
+ log.Fatal(err)
86
+ }
87
+ srv, err := api.NewServerWithPermissions(auth, permissions, app.Routes(cfg)...)
88
+ if err != nil {
89
+ log.Fatal(err)
90
+ }
91
+ if err := app.Migrate(cfg); err != nil {
92
+ log.Fatalf("schema migration failed: %v", err)
93
+ }
94
+ var handler http.Handler = srv
95
+ if standalone != nil {
96
+ handler = standalone.Wrap(handler)
97
+ }
98
+ if err := cgi.Serve(api.WithRequestDeadline(handler)); err != nil {
99
+ log.Fatal(err)
100
+ }
101
+ }
@@ -0,0 +1,38 @@
1
+ // Command background-worker is the package's fixed background worker. DSM runs
2
+ // it as the package's systemd user unit under the package account; it never
3
+ // runs as root and takes no input except the jobs in its private spool.
4
+ package main
5
+
6
+ import (
7
+ "context"
8
+ "log"
9
+ "os"
10
+ "os/signal"
11
+ "os/user"
12
+ "strconv"
13
+ "syscall"
14
+
15
+ "github.com/synopackageland/syno-pkg-land-sdk/background"
16
+ )
17
+
18
+ // Set by `synopkgland build-spk` through -ldflags -X.
19
+ var packageID = "dev"
20
+
21
+ func main() {
22
+ log.SetFlags(0)
23
+ log.SetPrefix(packageID + " background: ")
24
+ if err := background.RefuseRoot(os.Geteuid()); err != nil {
25
+ log.Fatal(err)
26
+ }
27
+ name := ""
28
+ if u, err := user.Current(); err == nil {
29
+ name = u.Username
30
+ }
31
+ info := background.WorkerInfo{PID: os.Getpid(), UID: os.Getuid(), Username: name}
32
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
33
+ defer stop()
34
+ log.Printf("started as uid %s", strconv.Itoa(info.UID))
35
+ if err := background.NewRunner("/var/packages/"+packageID+"/var/background", info).Run(ctx); err != nil {
36
+ log.Fatal(err)
37
+ }
38
+ }
@@ -0,0 +1,9 @@
1
+ module __PACKAGE__
2
+
3
+ go 1.25.0
4
+
5
+ toolchain go1.26.9
6
+
7
+ require github.com/synopackageland/syno-pkg-land-sdk v0.9.1
8
+
9
+ require golang.org/x/sys v0.44.0 // indirect
@@ -0,0 +1,4 @@
1
+ github.com/synopackageland/syno-pkg-land-sdk v0.9.1 h1:Mni+769M/fldZTlv0IOZKeaK3xGu6hTSkTeWIALPqBg=
2
+ github.com/synopackageland/syno-pkg-land-sdk v0.9.1/go.mod h1:JNoqISvR4biA9IuLi0CFe4c272tsKzCN9Nyj71nrXsQ=
3
+ golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
4
+ golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -0,0 +1,26 @@
1
+ // Package api holds this App's method policy. The SDK's own capability
2
+ // methods keep the SDK policy; this file only configures App methods.
3
+ package api
4
+
5
+ import (
6
+ "crypto/sha256"
7
+ _ "embed"
8
+ "errors"
9
+ "fmt"
10
+ )
11
+
12
+ //go:embed permissions.json
13
+ var permissionsJSON []byte
14
+
15
+ // Set by `synopkgland build` through -ldflags -X to bind the policy it
16
+ // validated to the compiled CGI. Empty only in direct Go development builds.
17
+ var expectedPermissionsDigest string
18
+
19
+ // Permissions returns permissions.json, refusing a file that differs from the
20
+ // one the package build validated.
21
+ func Permissions() ([]byte, error) {
22
+ if expectedPermissionsDigest != "" && fmt.Sprintf("%x", sha256.Sum256(permissionsJSON)) != expectedPermissionsDigest {
23
+ return nil, errors.New("API permissions do not match package build policy")
24
+ }
25
+ return permissionsJSON, nil
26
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "schema": 1,
3
+ "default": "administrators",
4
+ "methods": {}
5
+ }
@@ -0,0 +1,30 @@
1
+ package api
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "fmt"
6
+ "testing"
7
+ )
8
+
9
+ func TestPermissionsWithoutABuildDigestAreTheEmbeddedFile(t *testing.T) {
10
+ got, err := Permissions()
11
+ if err != nil || string(got) != string(permissionsJSON) {
12
+ t.Fatalf("Permissions() = %q, %v", got, err)
13
+ }
14
+ }
15
+
16
+ func TestPermissionsMatchingTheBuildDigestAreAccepted(t *testing.T) {
17
+ t.Cleanup(func() { expectedPermissionsDigest = "" })
18
+ expectedPermissionsDigest = fmt.Sprintf("%x", sha256.Sum256(permissionsJSON))
19
+ if _, err := Permissions(); err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ }
23
+
24
+ func TestPermissionsDifferingFromTheBuildDigestAreRefused(t *testing.T) {
25
+ t.Cleanup(func() { expectedPermissionsDigest = "" })
26
+ expectedPermissionsDigest = fmt.Sprintf("%x", sha256.Sum256([]byte("other")))
27
+ if got, err := Permissions(); err == nil || got != nil {
28
+ t.Fatalf("Permissions() = %q, %v; want refusal", got, err)
29
+ }
30
+ }