@synopackageland/templates 0.9.9 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -27
- package/package.json +2 -2
- package/templates/_shared/AGENTS.md +53 -15
- package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
- package/templates/native-go/backend/cmd/app/main.go +15 -1
- package/templates/native-go/backend/cmd/app/main_test.go +33 -0
- package/templates/native-go/backend/go.mod +1 -1
- package/templates/native-go/backend/go.sum +2 -2
- package/templates/native-go/i18n/messages/de.json +4 -0
- package/templates/native-go/i18n/messages/en.json +4 -0
- package/templates/native-go/i18n/messages/es.json +4 -0
- package/templates/native-go/i18n/messages/fr.json +4 -0
- package/templates/native-go/i18n/messages/it.json +4 -0
- package/templates/native-go/i18n/messages/ja.json +4 -0
- package/templates/native-go/i18n/messages/ko.json +4 -0
- package/templates/native-go/i18n/messages/zh-CN.json +4 -0
- package/templates/native-go/i18n/messages/zh-TW.json +4 -0
- package/templates/native-go/ui/app.js +2 -1
- package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +3 -2
- package/templates/native-go-jobs/frontend/test/background-page.test.ts +4 -4
- package/templates/native-go-vue/README.md +19 -7
- package/templates/native-go-vue/frontend/package-lock.json +9 -9
- package/templates/native-go-vue/frontend/package.json +2 -2
- package/templates/native-go-vue/frontend/src/App.vue +31 -36
- package/templates/native-go-vue/frontend/test/apps.test.ts +65 -12
- package/templates/native-go-vue/frontend/test/context.ts +8 -0
- package/templates/native-go-vue/frontend/test/native-controls.test.ts +3 -4
- package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +2 -2
- package/templates/native-node/README.md +153 -0
- package/templates/native-node/backend/app/package.json +4 -0
- package/templates/native-node/backend/app/permissions.json +5 -0
- package/templates/native-node/backend/package-lock.json +59 -0
- package/templates/native-node/backend/package.json +17 -0
- package/templates/native-node/backend/src/delegate.ts +5 -0
- package/templates/native-node/backend/src/jobs.ts +5 -0
- package/templates/native-node/backend/src/main.ts +4 -0
- package/templates/native-node/backend/src/params.ts +11 -0
- package/templates/native-node/backend/src/root-ops.ts +5 -0
- package/templates/native-node/backend/src/routes.ts +97 -0
- package/templates/native-node/backend/src/service.ts +22 -0
- package/templates/native-node/backend/test/routes.test.ts +75 -0
- package/templates/native-node/backend/test/support.ts +67 -0
- package/templates/native-node/backend/tsconfig.build.json +8 -0
- package/templates/native-node/backend/tsconfig.json +18 -0
- package/templates/native-node/dsm-package.yaml +80 -0
- package/templates/native-node/gitignore +4 -0
- package/templates/native-node-delegate/backend/src/delegate.ts +35 -0
- package/templates/native-node-delegate/backend/test/delegate.test.ts +51 -0
- package/templates/native-node-jobs/backend/src/jobs.ts +38 -0
- package/templates/native-node-jobs/backend/test/jobs.test.ts +73 -0
- package/templates/native-node-root-ops/backend/src/root-ops.ts +16 -0
- package/templates/native-node-root-ops/backend/test/root-ops.test.ts +23 -0
- package/templates/native-node-vue/README.md +56 -0
- package/templates/native-node-vue/gitignore +4 -0
- package/templates/native-python/README.md +150 -0
- package/templates/native-python/backend/app/delegate.py +5 -0
- package/templates/native-python/backend/app/jobs.py +5 -0
- package/templates/native-python/backend/app/main.py +5 -0
- package/templates/native-python/backend/app/params.py +10 -0
- package/templates/native-python/backend/app/permissions.json +5 -0
- package/templates/native-python/backend/app/root_ops.py +5 -0
- package/templates/native-python/backend/app/routes.py +107 -0
- package/templates/native-python/backend/tests/support.py +72 -0
- package/templates/native-python/backend/tests/test_routes.py +73 -0
- package/templates/native-python/dsm-package.yaml +77 -0
- package/templates/native-python/gitignore +2 -0
- package/templates/native-python-delegate/backend/app/delegate.py +42 -0
- package/templates/native-python-delegate/backend/tests/test_delegate.py +58 -0
- package/templates/native-python-jobs/backend/app/jobs.py +40 -0
- package/templates/native-python-jobs/backend/tests/test_jobs.py +72 -0
- package/templates/native-python-root-ops/backend/app/root_ops.py +18 -0
- package/templates/native-python-root-ops/backend/tests/test_root_ops.py +34 -0
- package/templates/native-python-vue/README.md +56 -0
- package/templates/native-python-vue/gitignore +3 -0
- package/templates/native-rust/README.md +74 -296
- package/templates/native-rust/backend/Cargo.lock +6 -6
- package/templates/native-rust/backend/Cargo.toml +2 -10
- package/templates/native-rust/backend/src/delegate.rs +6 -0
- package/templates/native-rust/backend/src/jobs.rs +6 -0
- package/templates/native-rust/backend/src/main.rs +12 -0
- package/templates/native-rust/backend/src/root_ops.rs +6 -0
- package/templates/native-rust/backend/src/routes.rs +352 -0
- package/templates/native-rust/backend/src/test_support.rs +91 -0
- package/templates/native-rust/dsm-package.yaml +10 -21
- package/templates/native-rust-delegate/backend/src/delegate.rs +171 -0
- package/templates/native-rust-jobs/backend/src/jobs.rs +240 -0
- package/templates/native-rust-root-ops/backend/src/root_ops.rs +83 -0
- package/templates/native-rust-vue/README.md +35 -34
- package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +0 -207
- package/templates/native-rust/backend/src/app/background/mod.rs +0 -424
- package/templates/native-rust/backend/src/app/background/owner.rs +0 -77
- package/templates/native-rust/backend/src/app/background/owner_tests.rs +0 -174
- package/templates/native-rust/backend/src/app/background/schedule.rs +0 -126
- package/templates/native-rust/backend/src/app/background/schedule_tests.rs +0 -108
- package/templates/native-rust/backend/src/app/background/tests.rs +0 -505
- package/templates/native-rust/backend/src/app/background/work.rs +0 -78
- package/templates/native-rust/backend/src/app/background/work_tests.rs +0 -123
- package/templates/native-rust/backend/src/app/background_routes.rs +0 -22
- package/templates/native-rust/backend/src/app/delegate_config.rs +0 -188
- package/templates/native-rust/backend/src/app/delegate_routes.rs +0 -22
- package/templates/native-rust/backend/src/app/identity.rs +0 -56
- package/templates/native-rust/backend/src/app/migrations.rs +0 -79
- package/templates/native-rust/backend/src/app/mod.rs +0 -30
- package/templates/native-rust/backend/src/app/portal.rs +0 -317
- package/templates/native-rust/backend/src/app/root_routes.rs +0 -23
- package/templates/native-rust/backend/src/app/routes.rs +0 -315
- package/templates/native-rust/backend/src/app/session_files.rs +0 -19
- package/templates/native-rust/backend/src/app/shared_folder.rs +0 -383
- package/templates/native-rust/backend/src/app/test_support.rs +0 -202
- package/templates/native-rust/backend/src/app/uninstall.rs +0 -38
- package/templates/native-rust/backend/src/bin/api.rs +0 -72
- package/templates/native-rust/backend/src/bin/background-worker.rs +0 -37
- package/templates/native-rust/backend/src/build_env.rs +0 -155
- package/templates/native-rust/backend/src/lib.rs +0 -6
- package/templates/native-rust/backend/src/permissions.rs +0 -54
- package/templates/native-rust/backend/src/process.rs +0 -91
- package/templates/native-rust/i18n/glossary.json +0 -665
- package/templates/native-rust/i18n/messages/de.json +0 -32
- package/templates/native-rust/i18n/messages/en.json +0 -32
- package/templates/native-rust/i18n/messages/es.json +0 -32
- package/templates/native-rust/i18n/messages/fr.json +0 -32
- package/templates/native-rust/i18n/messages/it.json +0 -32
- package/templates/native-rust/i18n/messages/ja.json +0 -32
- package/templates/native-rust/i18n/messages/ko.json +0 -32
- package/templates/native-rust/i18n/messages/zh-CN.json +0 -32
- package/templates/native-rust/i18n/messages/zh-TW.json +0 -32
- package/templates/native-rust/ui/app.css +0 -45
- package/templates/native-rust/ui/app.js +0 -111
- package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +0 -92
- package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +0 -52
- package/templates/native-rust-jobs/backend/src/app/background_routes.rs +0 -109
- package/templates/native-rust-root-ops/backend/src/app/root_routes.rs +0 -475
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Native Node.js with Vue
|
|
2
|
+
|
|
3
|
+
The Vue shell is the same as the Go template's. Edit `frontend/src/App.vue`
|
|
4
|
+
and `main.ts` to build a DSM-window app with the public `@synopackageland/ui`
|
|
5
|
+
components and embedded theme. The shell, sign-in, files, portal, jobs and
|
|
6
|
+
delegate components come from `@synopackageland/sdk/vue` (types from
|
|
7
|
+
`@synopackageland/sdk`); its version must equal the `backend.sdk` in dsm-package.yaml. See
|
|
8
|
+
<https://github.com/synopackageland/syno-pkg-land-sdk> for the components and their props.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
cd frontend
|
|
12
|
+
npm ci
|
|
13
|
+
npm test
|
|
14
|
+
npm run build
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The base app reads package status and edits the package greeting through
|
|
18
|
+
`ctx.call`. Errors show the app's own `frontend/src/i18n` text, or the SDK's
|
|
19
|
+
`bridgeErrorCopy` for the codes the bridge runtime adds (`app_unavailable`
|
|
20
|
+
and the services' refusals). Each window has its own Vue mount, request state
|
|
21
|
+
and host subscriptions; unmount disposes subscriptions and ignores late responses.
|
|
22
|
+
|
|
23
|
+
The `--with jobs`, `root-ops` and `delegate` examples add their methods to
|
|
24
|
+
the Node.js App; the shell's panels call them with `ctx.call`. The SDK's
|
|
25
|
+
background-jobs page and executor settings call the runtime's own browser
|
|
26
|
+
methods (`bg.*`, `executor.*`); check the SDK release notes for the ones a
|
|
27
|
+
bridge App's runtime serves.
|
|
28
|
+
|
|
29
|
+
Use the installed `@synopackageland/ui`; package.json pins its version.
|
|
30
|
+
`App.vue` lays the shell out with the SDK's `AppShell` (`@synopackageland/sdk/vue`):
|
|
31
|
+
`pages` from `shellPages`, the header buttons in `#actions` and the pages in its
|
|
32
|
+
default slot. Given `ctx`, it follows the launch. In a DSM desktop window it
|
|
33
|
+
keeps `wide-layout="window"`, even maximized; the DSM host sets the 700px
|
|
34
|
+
minimum window width. A Portal or standalone launch owns the whole page
|
|
35
|
+
(`ctx.fullPage`, from the generated window): `AppShell` takes
|
|
36
|
+
`wide-layout="web"` (the SaaS layout at 1200px or wider, mobile on a phone) and
|
|
37
|
+
a device-width viewport, undone at unmount, since DSM's page has no
|
|
38
|
+
device-width viewport. Its full-page sidebar names the App with
|
|
39
|
+
`ctx.displayName` and `ctx.appIcon` (without them the page title and icon) and
|
|
40
|
+
shows the signed-in DSM account and its role from `ctx.user`. See the ui's
|
|
41
|
+
`docs/adaptive-layout.md`.
|
|
42
|
+
`AppShell`'s `app-root` wraps it in an app-local `AppRoot` (app modal scope, on
|
|
43
|
+
a full page as well, bounded at `height: 100%`) with the UI locale from
|
|
44
|
+
`locale`; do not add another `AppRoot`.
|
|
45
|
+
In a DSM window (not a full page) that `AppRoot` takes
|
|
46
|
+
`dialog-bounds="viewport"`, so Dialogs can be dragged out of the window, and
|
|
47
|
+
pressing one calls `ctx.host.activateWindow()` to bring the window to the
|
|
48
|
+
front. Elsewhere Dialogs stay inside the App. A confirmed sign-in need sets
|
|
49
|
+
`denied`: the `#denied` slot's `LoginPage` takes the place of the App's pages.
|
|
50
|
+
Import `themes/dsm7-embedded.css` and assign each mount a unique
|
|
51
|
+
`app.config.idPrefix` before mounting.
|
|
52
|
+
|
|
53
|
+
Start with AGENTS.md, `synopkgland skill list` and `synopkgland skill path`.
|
|
54
|
+
Read `synopkgland skill show ui-vue` for direct Vue component work. For UI
|
|
55
|
+
discovery, run `npx --no syno-pkg-land-ui catalog --json` and
|
|
56
|
+
`npx --no syno-pkg-land-ui describe Button --json` from the installed frontend.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# __DISPLAY_NAME__
|
|
2
|
+
|
|
3
|
+
A native DSM package (`__PACKAGE__`): a Python App behind the SDK's runtime,
|
|
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 (the SDK runtime, runs as package user)
|
|
12
|
+
1. transport guards: POST, JSON, same origin, ≤ 1 MiB, bounded JSON complexity
|
|
13
|
+
2. one DSM authentication, the entry gate and app/permissions.json
|
|
14
|
+
3. DSM's own Python 3 (3.8 or later) runs `backend/app/main.py` once per call
|
|
15
|
+
4. your method, which may ask the runtime for services (files, jobs, executor)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
Needs Node.js with `synopkgland` installed, and Python 3.8 or later.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
(cd backend && python3 -m unittest discover -s tests) # method 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
|
+
## Project layout
|
|
35
|
+
|
|
36
|
+
| Path | What it is | Edit it? |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| `dsm-package.yaml` | Package identity, version/build, arches, where backend and UI come from | Yes |
|
|
39
|
+
| `ui/app.js`, `ui/app.css` | The app inside the DSM window | Yes — your UI |
|
|
40
|
+
| `i18n/messages/<locale>.json` | UI text in nine languages | Yes |
|
|
41
|
+
| `i18n/glossary.json` | DSM's own UI terms, and which messages must use them | Add terms |
|
|
42
|
+
| `backend/app/routes.py` | Your methods: `build` registers them | Yes — your backend |
|
|
43
|
+
| `backend/app/jobs.py`, `root_ops.py`, `delegate.py` | Capability examples (stubs unless `init --with` added them) | Yes |
|
|
44
|
+
| `backend/app/permissions.json` | Who may call each of your methods | Yes |
|
|
45
|
+
| `backend/app/main.py` | Runs the App on the runtime's channel | Rarely |
|
|
46
|
+
| `backend/tests/` | unittest tests; `support.call` plays the runtime | Yes |
|
|
47
|
+
| `backend/vendor/synopkgland_app` | The Python SDK for tests, copied by `synopkgland init` from the SDK release; the build ships its own copy of the release `backend.sdk` names | No |
|
|
48
|
+
| `dist/` | Built SPKs (ignored by git) | No |
|
|
49
|
+
|
|
50
|
+
`build` generates everything else: `INFO`, `conf/privilege`
|
|
51
|
+
(`run-as: package`), lifecycle scripts, icons, the DSM window wrapper and the
|
|
52
|
+
runtime CGI at `ui/cgi/api.cgi`.
|
|
53
|
+
|
|
54
|
+
## Add a method
|
|
55
|
+
|
|
56
|
+
Register it in `build`. Every method defaults to DSM administrator membership;
|
|
57
|
+
all access rules live in `backend/app/permissions.json`:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
def build(cfg):
|
|
61
|
+
app = App()
|
|
62
|
+
...
|
|
63
|
+
app.method("notes.list")(lambda ctx, params: list_notes(cfg, ctx, params)) # new: administrators by default
|
|
64
|
+
return app
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def list_notes(cfg, ctx, params):
|
|
68
|
+
p = fields(params, folder=str) # anything else is bad_request
|
|
69
|
+
return {"owner": ctx.user, "notes": []}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Add a test to `backend/tests/` with `support.call(app, "notes.list", {"folder": "work"}, services={...})`. Call it from the UI with `ctx.call("notes.list", { folder: "work" })`.
|
|
73
|
+
|
|
74
|
+
Raise `Error(code, message)` (or `Error.bad_request(message)`) for an error the browser may see; any other exception is `internal`, with the traceback in the package's log. A refusal from `ctx.runtime` is an `Error` too, so the browser sees the runtime's code.
|
|
75
|
+
|
|
76
|
+
The runtime refuses before your App runs: `session_required`, `admin_required`,
|
|
77
|
+
`unknown_method`, `request_timeout` and the transport guards' codes. The UI
|
|
78
|
+
shows `error.<code>` from its catalogs.
|
|
79
|
+
|
|
80
|
+
### Who can call what
|
|
81
|
+
|
|
82
|
+
The shipped policy is:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{"schema": 1, "default": "administrators", "methods": {}}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The default must remain `administrators`. An intentional exception belongs in
|
|
89
|
+
`methods`, for example `"notes.list": "authenticated"`; `"public"` explicitly
|
|
90
|
+
allows anonymous calls. Such exceptions also enable ordinary-user launcher
|
|
91
|
+
access.
|
|
92
|
+
|
|
93
|
+
| JSON rule | No session | Ordinary user | Administrators group member |
|
|
94
|
+
| --- | --- | --- | --- |
|
|
95
|
+
| absent or `administrators` | `session_required` | `admin_required` | runs |
|
|
96
|
+
| explicit `authenticated` | `session_required` | runs | runs |
|
|
97
|
+
| explicit `public` | runs | runs | runs |
|
|
98
|
+
| method the App does not register | `session_required` | `unknown_method` | `unknown_method` |
|
|
99
|
+
|
|
100
|
+
Missing or invalid JSON, duplicate keys, unknown permissions and rules for
|
|
101
|
+
methods the App does not register fail closed. Changing policy requires
|
|
102
|
+
rebuilding the SPK.
|
|
103
|
+
|
|
104
|
+
## Capabilities
|
|
105
|
+
|
|
106
|
+
`synopkgland init --with` adds examples that use the runtime's services through
|
|
107
|
+
`ctx.runtime`; each comes with its tests:
|
|
108
|
+
|
|
109
|
+
| `--with` | Methods | Runtime services |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | `files.read` with the caller's session, `bg.submit`, `bg.get`, `bg.cancel` |
|
|
112
|
+
| `root-ops` | `whoami.run` | `root.run` of `root/whoami.sh`, once an administrator approves the executor |
|
|
113
|
+
| `delegate` | `report.save` | `delegate.move_from_staging` into a folder the caller chose |
|
|
114
|
+
|
|
115
|
+
A service refuses with its own code (`limit_exceeded`, `read_failed`, the
|
|
116
|
+
executor's refusals); the examples pass it on to the browser. `synopkgland add`
|
|
117
|
+
works on Go backends only: start a Python App with the capabilities it needs.
|
|
118
|
+
|
|
119
|
+
## The SDK
|
|
120
|
+
|
|
121
|
+
`backend.sdk` in dsm-package.yaml names the SDK release: the build ships its runtime and its Python SDK as `backend/app/vendor/synopkgland_app`, and reads the method list from `main.py --describe`. `backend/vendor/synopkgland_app` is the same SDK for tests; replace it with the new release's when you change `backend.sdk`. Upgrade the SDK by changing that version; `synopkgland check`
|
|
122
|
+
refuses versions this CLI does not support. The bridge protocol, the services
|
|
123
|
+
and their codes are documented in the SDK repository:
|
|
124
|
+
<https://github.com/synopackageland/syno-pkg-land-sdk> (`spec/bridge/v1`).
|
|
125
|
+
|
|
126
|
+
## UI and languages
|
|
127
|
+
|
|
128
|
+
The UI is the same as the Go template's. `ui/app.js` registers
|
|
129
|
+
`window.DsmPackageApps[<package>].mount(root, ctx)`; `ctx.call(method, params)`
|
|
130
|
+
is the only transport and `ctx.t(key, values)` reads
|
|
131
|
+
`i18n/messages/<locale>.json` (`en`, `zh-TW`, `zh-CN`, `ja`, `ko`, `de`,
|
|
132
|
+
`fr`, `es`, `it`). Add `error.<code>` for every code your methods return.
|
|
133
|
+
`synopkgland check` and `build` refuse a language file with missing or extra
|
|
134
|
+
keys, a dropped `{placeholder}` or a listed message that drifts from the
|
|
135
|
+
glossary. `--ui vue` gives the Vue shell in `frontend/` instead.
|
|
136
|
+
|
|
137
|
+
## Data, upgrades, uninstall
|
|
138
|
+
|
|
139
|
+
The App stores data in `/var/packages/__PACKAGE__/var` (`SYNOPKGLAND_DATA_DIR`,
|
|
140
|
+
kept across upgrades). Bump `build` for every SPK you hand out and `version`
|
|
141
|
+
for feature releases. Uninstalling keeps the data unless the administrator
|
|
142
|
+
ticks **Also delete the app data listed above** in the uninstall wizard.
|
|
143
|
+
|
|
144
|
+
## Support matrix
|
|
145
|
+
|
|
146
|
+
| | Status |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| Build hosts | macOS, Linux |
|
|
149
|
+
| Target CPUs | x86_64 and aarch64 DSM models (one SPK each); armv7 is opt-in in `arches`, built but not NAS-verified |
|
|
150
|
+
| DSM | 7.1 or later declared (`os_min_ver`) |
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Request params, checked strictly: the browser sends what it likes."""
|
|
2
|
+
|
|
3
|
+
from synopkgland_app import Error
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fields(params, **types):
|
|
7
|
+
"""params as an object with exactly these fields of these types, else bad_request."""
|
|
8
|
+
if not isinstance(params, dict) or set(params) != set(types) or not all(isinstance(params[k], t) for k, t in types.items()):
|
|
9
|
+
raise Error.bad_request("params must be {%s}" % ", ".join(sorted(types)))
|
|
10
|
+
return params
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The App's methods. Register a method in build() and give it a rule in
|
|
2
|
+
permissions.json unless only administrators may call it: the runtime has
|
|
3
|
+
checked the DSM session, the entry gate and that rule before a handler runs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from synopkgland_app import App, Error
|
|
14
|
+
|
|
15
|
+
import delegate
|
|
16
|
+
import jobs
|
|
17
|
+
import root_ops
|
|
18
|
+
from params import fields
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Config:
|
|
22
|
+
"""Where the App's state lives; the runtime sets the environment."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, package, data_dir, packages_root="/var/packages"):
|
|
25
|
+
self.package = package
|
|
26
|
+
# The package's own data directory (/var/packages/<package>/var), kept across upgrades.
|
|
27
|
+
self.data_dir = data_dir
|
|
28
|
+
self.packages_root = packages_root
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_env(cls):
|
|
32
|
+
return cls(os.environ.get("SYNOPKGLAND_PACKAGE", ""), os.environ.get("SYNOPKGLAND_DATA_DIR", ""))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build(cfg):
|
|
36
|
+
app = App()
|
|
37
|
+
app.method("app.status")(lambda ctx, params: status(cfg, ctx))
|
|
38
|
+
app.method("greeting.get")(lambda ctx, params: get_greeting(cfg))
|
|
39
|
+
app.method("greeting.set")(lambda ctx, params: set_greeting(cfg, ctx, params))
|
|
40
|
+
# Each capability module is a stub until `synopkgland init --with` adds its example.
|
|
41
|
+
for capability in (jobs, root_ops, delegate):
|
|
42
|
+
capability.register(app, cfg)
|
|
43
|
+
return app
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def status(cfg, ctx):
|
|
47
|
+
result = {
|
|
48
|
+
"package": cfg.package,
|
|
49
|
+
"version": installed_version(cfg),
|
|
50
|
+
"user": ctx.user,
|
|
51
|
+
"admin": ctx.admin,
|
|
52
|
+
"process": {"uid": os.getuid(), "gid": os.getgid()},
|
|
53
|
+
"language": "Python " + platform.python_version(),
|
|
54
|
+
"platform": sys.platform + "/" + platform.machine(),
|
|
55
|
+
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
56
|
+
}
|
|
57
|
+
try:
|
|
58
|
+
# Served only when dsm-package.yaml declares a portal.
|
|
59
|
+
result["portal"] = ctx.runtime("portal.status")
|
|
60
|
+
except Error:
|
|
61
|
+
pass
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def installed_version(cfg):
|
|
66
|
+
"""The version in the package's INFO, or "" outside DSM."""
|
|
67
|
+
try:
|
|
68
|
+
with open(os.path.join(cfg.packages_root, cfg.package, "INFO"), encoding="utf-8") as info:
|
|
69
|
+
for line in info:
|
|
70
|
+
if line.startswith("version="):
|
|
71
|
+
return line[len("version=") :].strip().strip('"')
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
return ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def greeting_path(cfg):
|
|
78
|
+
return os.path.join(cfg.data_dir, "greeting.json")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_greeting(cfg):
|
|
82
|
+
try:
|
|
83
|
+
with open(greeting_path(cfg), encoding="utf-8") as f:
|
|
84
|
+
return json.load(f)
|
|
85
|
+
except FileNotFoundError:
|
|
86
|
+
return {"text": "Hello"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def set_greeting(cfg, ctx, params):
|
|
90
|
+
text = fields(params, text=str)["text"]
|
|
91
|
+
if not 1 <= len(text) <= 200:
|
|
92
|
+
raise Error.bad_request("text must be 1 to 200 characters")
|
|
93
|
+
greeting = {"text": text, "updated_by": ctx.user}
|
|
94
|
+
write_file_atomic(greeting_path(cfg), json.dumps(greeting).encode())
|
|
95
|
+
return greeting
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def write_file_atomic(path, data):
|
|
99
|
+
"""Replace path in one rename, so readers never see half a file."""
|
|
100
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".tmp-")
|
|
101
|
+
try:
|
|
102
|
+
with os.fdopen(fd, "wb") as f:
|
|
103
|
+
f.write(data)
|
|
104
|
+
os.replace(tmp, path)
|
|
105
|
+
except BaseException:
|
|
106
|
+
os.unlink(tmp)
|
|
107
|
+
raise
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Calls an App method the way the runtime does: over a channel, with the
|
|
2
|
+
hello, the call, the services the method asks for and its result."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
_backend = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
10
|
+
# The App's own directory, and the SDK the runtime ships in the App's vendor/.
|
|
11
|
+
sys.path[:0] = [os.path.join(_backend, "app"), os.path.join(_backend, "vendor")]
|
|
12
|
+
|
|
13
|
+
from synopkgland_app import Error # noqa: E402
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Reply:
|
|
17
|
+
"""A call's result frame, and the services the method asked for in order."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, frame, services):
|
|
20
|
+
self.ok = frame["ok"]
|
|
21
|
+
self.data = frame.get("data")
|
|
22
|
+
self.error = frame.get("error")
|
|
23
|
+
self.services = services
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def call(app, method, params=None, user="admin", admin=True, services=None):
|
|
27
|
+
"""Answer one call of method; services maps an op to fn(params), which
|
|
28
|
+
returns the result or raises Error. An op it lacks is unknown_service."""
|
|
29
|
+
services = services or {}
|
|
30
|
+
to_app, app_in = os.pipe()
|
|
31
|
+
app_out, from_app = os.pipe()
|
|
32
|
+
rfile, wfile = os.fdopen(to_app, "rb"), os.fdopen(from_app, "wb")
|
|
33
|
+
|
|
34
|
+
def serve():
|
|
35
|
+
try:
|
|
36
|
+
app.serve(rfile, wfile)
|
|
37
|
+
finally:
|
|
38
|
+
wfile.close()
|
|
39
|
+
|
|
40
|
+
thread = threading.Thread(target=serve, daemon=True)
|
|
41
|
+
thread.start()
|
|
42
|
+
out, inp = os.fdopen(app_in, "wb"), os.fdopen(app_out, "rb")
|
|
43
|
+
send = lambda frame: (out.write(json.dumps(frame).encode() + b"\n"), out.flush())
|
|
44
|
+
send({"t": "hello", "versions": [1]})
|
|
45
|
+
inp.readline()
|
|
46
|
+
ctx = {"user": user, "admin": admin, "locale": "en", "request_id": "0" * 32, "deadline_ms": 30000}
|
|
47
|
+
send({"t": "call", "id": "1", "method": method, "params": params, "ctx": ctx})
|
|
48
|
+
asked = []
|
|
49
|
+
try:
|
|
50
|
+
for line in inp:
|
|
51
|
+
frame = json.loads(line)
|
|
52
|
+
if frame["t"] == "result":
|
|
53
|
+
return Reply(frame, asked)
|
|
54
|
+
asked.append((frame["op"], frame["params"]))
|
|
55
|
+
send(_answer(services, frame))
|
|
56
|
+
raise AssertionError("the App ended the channel without a result")
|
|
57
|
+
finally:
|
|
58
|
+
out.close()
|
|
59
|
+
thread.join(5)
|
|
60
|
+
rfile.close()
|
|
61
|
+
inp.close()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _answer(services, frame):
|
|
65
|
+
reply = {"t": "svc_result", "id": frame["id"]}
|
|
66
|
+
service = services.get(frame["op"])
|
|
67
|
+
try:
|
|
68
|
+
if service is None:
|
|
69
|
+
raise Error("unknown_service", "no service " + frame["op"])
|
|
70
|
+
return dict(reply, ok=True, result=service(frame["params"]))
|
|
71
|
+
except Error as e:
|
|
72
|
+
return dict(reply, ok=False, error={"code": e.code, "message": e.message})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from support import call
|
|
7
|
+
|
|
8
|
+
import routes
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RoutesTest(unittest.TestCase):
|
|
12
|
+
def setUp(self):
|
|
13
|
+
self.dir = tempfile.TemporaryDirectory()
|
|
14
|
+
self.addCleanup(self.dir.cleanup)
|
|
15
|
+
root = self.dir.name
|
|
16
|
+
self.data = os.path.join(root, "var")
|
|
17
|
+
os.mkdir(self.data)
|
|
18
|
+
self.cfg = routes.Config("ExampleApp", self.data, packages_root=os.path.join(root, "packages"))
|
|
19
|
+
self.app = routes.build(self.cfg)
|
|
20
|
+
|
|
21
|
+
def write_info(self, text):
|
|
22
|
+
os.makedirs(os.path.join(self.cfg.packages_root, "ExampleApp"))
|
|
23
|
+
with open(os.path.join(self.cfg.packages_root, "ExampleApp", "INFO"), "w") as f:
|
|
24
|
+
f.write(text)
|
|
25
|
+
|
|
26
|
+
def test_status_reports_the_caller_and_the_installed_version(self):
|
|
27
|
+
self.write_info('package="ExampleApp"\nversion="0.1.0-0001"\n')
|
|
28
|
+
reply = call(self.app, "app.status", user="alice", admin=False)
|
|
29
|
+
self.assertTrue(reply.ok, reply.error)
|
|
30
|
+
status = reply.data
|
|
31
|
+
self.assertEqual((status["package"], status["version"]), ("ExampleApp", "0.1.0-0001"))
|
|
32
|
+
self.assertEqual((status["user"], status["admin"]), ("alice", False))
|
|
33
|
+
self.assertEqual(status["process"], {"uid": os.getuid(), "gid": os.getgid()})
|
|
34
|
+
self.assertTrue(status["language"].startswith("Python 3."), status["language"])
|
|
35
|
+
self.assertRegex(status["platform"], r"^\w+/\w+$")
|
|
36
|
+
self.assertRegex(status["time"], r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$")
|
|
37
|
+
|
|
38
|
+
def test_status_without_info_has_no_version(self):
|
|
39
|
+
self.assertEqual(call(self.app, "app.status").data["version"], "")
|
|
40
|
+
|
|
41
|
+
def test_status_shows_the_portal_when_the_runtime_offers_it(self):
|
|
42
|
+
portal = {"enabled": True, "alias": "example"}
|
|
43
|
+
reply = call(self.app, "app.status", services={"portal.status": lambda params: portal})
|
|
44
|
+
self.assertEqual(reply.data["portal"], portal)
|
|
45
|
+
self.assertEqual(reply.services, [("portal.status", None)])
|
|
46
|
+
|
|
47
|
+
def test_status_without_a_portal_leaves_it_out(self):
|
|
48
|
+
reply = call(self.app, "app.status")
|
|
49
|
+
self.assertTrue(reply.ok, reply.error)
|
|
50
|
+
self.assertNotIn("portal", reply.data)
|
|
51
|
+
|
|
52
|
+
def test_greeting_defaults_to_hello(self):
|
|
53
|
+
self.assertEqual(call(self.app, "greeting.get").data, {"text": "Hello"})
|
|
54
|
+
|
|
55
|
+
def test_greeting_set_stores_the_text_and_who_set_it(self):
|
|
56
|
+
reply = call(self.app, "greeting.set", {"text": "Hi 👋"}, user="alice")
|
|
57
|
+
self.assertEqual(reply.data, {"text": "Hi 👋", "updated_by": "alice"})
|
|
58
|
+
self.assertEqual(call(self.app, "greeting.get").data, {"text": "Hi 👋", "updated_by": "alice"})
|
|
59
|
+
self.assertEqual(os.listdir(self.data), ["greeting.json"])
|
|
60
|
+
|
|
61
|
+
def test_greeting_counts_characters_not_bytes(self):
|
|
62
|
+
self.assertTrue(call(self.app, "greeting.set", {"text": "é" * 200}).ok)
|
|
63
|
+
|
|
64
|
+
def test_greeting_set_refuses_invalid_params_and_keeps_the_old_text(self):
|
|
65
|
+
for params in [None, [], {}, {"text": ""}, {"text": "x" * 201}, {"text": 5}, {"text": "hi", "extra": 1}]:
|
|
66
|
+
with self.subTest(params=params):
|
|
67
|
+
reply = call(self.app, "greeting.set", params)
|
|
68
|
+
self.assertEqual(reply.error["code"], "bad_request")
|
|
69
|
+
self.assertEqual(os.listdir(self.data), [])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
unittest.main()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Standalone native DSM package: an unsigned SPK that runs as its own package
|
|
2
|
+
# `synopkgland build` reads this file.
|
|
3
|
+
schema: 2
|
|
4
|
+
|
|
5
|
+
# INFO.package: letters and digits, starting with a letter. It also names the
|
|
6
|
+
# DSM app (SYNO.SDS.<package>) and the CGI URL /webman/3rdparty/<package>/.
|
|
7
|
+
package: __PACKAGE__
|
|
8
|
+
version: 0.1.0
|
|
9
|
+
# Increase for every SPK you hand out with the same version (INFO 0.1.0-0001).
|
|
10
|
+
build: 1
|
|
11
|
+
displayname: __DISPLAY_NAME__
|
|
12
|
+
description: A native DSM package with a Python backend and a DSM window UI.
|
|
13
|
+
# Package Center and DSM window icon: a square PNG of at least 256x256, or a
|
|
14
|
+
# folder of icon_<size>.png. Omit to use the SDK default icon.
|
|
15
|
+
# icon: assets/icon.png
|
|
16
|
+
maintainer: Your Name
|
|
17
|
+
# Optional INFO/website metadata. Omit fields you do not need.
|
|
18
|
+
# metadata:
|
|
19
|
+
# contact_email: you@example.com
|
|
20
|
+
# support_email: support@example.com
|
|
21
|
+
# homepage: https://example.com
|
|
22
|
+
# source_url: https://github.com/you/your-app
|
|
23
|
+
# issues_url: https://github.com/you/your-app/issues
|
|
24
|
+
# license: MIT
|
|
25
|
+
# long_description:
|
|
26
|
+
# en: |-
|
|
27
|
+
# Explain what the app does and how to use it.
|
|
28
|
+
# zh-TW: Translated description.
|
|
29
|
+
# changelog: # text, or per-language like long_description
|
|
30
|
+
# en: |-
|
|
31
|
+
# - Most important change first. Omit to use Release notes.
|
|
32
|
+
# zh-TW: |-
|
|
33
|
+
# - Translated changes.
|
|
34
|
+
# screenshots: [https://example.com/screenshot.png]
|
|
35
|
+
os_min_ver: "7.1-42661"
|
|
36
|
+
|
|
37
|
+
# One SPK per architecture. Supported: x86_64, aarch64, armv7 (add it here to
|
|
38
|
+
# build it; armv7 is built but not NAS-verified).
|
|
39
|
+
arches: [x86_64, aarch64]
|
|
40
|
+
|
|
41
|
+
# The SDK's Go runtime is the CGI: it checks the DSM session, the entry gate
|
|
42
|
+
# and app/permissions.json, then runs `python3 main.py` in backend/app as the
|
|
43
|
+
# package account, once per call, with DSM's own Python (3.8 or later).
|
|
44
|
+
# sdk is the SDK release whose runtime and Python SDK (vendor/synopkgland_app)
|
|
45
|
+
# the build ships; the build reads the methods from `main.py --describe`.
|
|
46
|
+
backend:
|
|
47
|
+
runtime: python
|
|
48
|
+
dir: backend/app
|
|
49
|
+
entry: main.py
|
|
50
|
+
sdk: 0.10.0
|
|
51
|
+
|
|
52
|
+
ui:
|
|
53
|
+
# Static files served at /webman/3rdparty/<package>/app/. app.js and app.css
|
|
54
|
+
# are required; anything else (images, chunks) is copied as is.
|
|
55
|
+
dir: ui
|
|
56
|
+
|
|
57
|
+
# UI languages: i18n/messages/<locale>.json for en, zh-TW, zh-CN, ja, ko, de,
|
|
58
|
+
# fr, es and it (DSM's other languages fall back to English). build checks
|
|
59
|
+
# that every language has the en.json keys and placeholders, and that the keys
|
|
60
|
+
# listed in the glossary use DSM's own wording.
|
|
61
|
+
i18n:
|
|
62
|
+
messages: i18n/messages
|
|
63
|
+
glossary: i18n/glossary.json
|
|
64
|
+
# Package Center text per language (INFO displayname_<lang>/description_<lang>).
|
|
65
|
+
# English comes from displayname/description above.
|
|
66
|
+
info:
|
|
67
|
+
zh-TW: { description: 以 Python 後端與 DSM 視窗介面打造的原生 DSM 套件。 }
|
|
68
|
+
zh-CN: { description: 以 Python 后端与 DSM 窗口界面构建的原生 DSM 套件。 }
|
|
69
|
+
ja: { description: Python バックエンドと DSM ウィンドウ UI を備えたネイティブ DSM パッケージ。 }
|
|
70
|
+
ko: { description: Python 백엔드와 DSM 창 UI를 갖춘 네이티브 DSM 패키지. }
|
|
71
|
+
de: { description: Ein natives DSM-Paket mit Python-Backend und DSM-Fenster-Oberfläche. }
|
|
72
|
+
fr: { description: Un paquet DSM natif avec un backend Python et une interface en fenêtre DSM. }
|
|
73
|
+
es: { description: Un paquete nativo de DSM con backend en Python e interfaz en ventana de DSM. }
|
|
74
|
+
it: { description: Un pacchetto DSM nativo con backend Python e interfaccia in una finestra DSM. }
|
|
75
|
+
|
|
76
|
+
# Optional SDK capabilities; delegate/rootOps share administrator-approved executor setup.
|
|
77
|
+
capabilities: {}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Saves to a user's destination: write a report and move it there.
|
|
2
|
+
|
|
3
|
+
The browser picks the destination with the SDK's Background saves page and
|
|
4
|
+
sends its delegation ID; the executor checks that it is the caller's grant
|
|
5
|
+
and writes the file as the caller, never as root. A save that failed may be
|
|
6
|
+
repeated with the same job ID: the same file is sent again, and the executor
|
|
7
|
+
copies it at most once.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from synopkgland_app import Error
|
|
15
|
+
|
|
16
|
+
from params import fields
|
|
17
|
+
|
|
18
|
+
JOB_ID = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}\Z")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register(app, cfg):
|
|
22
|
+
app.method("report.save")(lambda ctx, params: save(cfg, ctx, params))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save(cfg, ctx, params):
|
|
26
|
+
p = fields(params, delegation_id=str, job_id=str)
|
|
27
|
+
if not JOB_ID.match(p["job_id"]):
|
|
28
|
+
raise Error.bad_request("job_id must be 1 to 128 letters, digits, '.', '_' or '-'")
|
|
29
|
+
name = "report-%s.txt" % p["job_id"]
|
|
30
|
+
# The runtime's delegate service moves files from here.
|
|
31
|
+
staging = os.path.join(cfg.data_dir, "executor-staging")
|
|
32
|
+
os.makedirs(staging, mode=0o700, exist_ok=True)
|
|
33
|
+
path = os.path.join(staging, name)
|
|
34
|
+
try:
|
|
35
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
36
|
+
except FileExistsError:
|
|
37
|
+
pass
|
|
38
|
+
else:
|
|
39
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
40
|
+
f.write("Report for %s, %s\n" % (ctx.user, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
|
|
41
|
+
move = {"delegation_id": p["delegation_id"], "job_id": p["job_id"], "staging": name, "name": name, "size": os.path.getsize(path)}
|
|
42
|
+
return ctx.runtime("delegate.move_from_staging", move)
|