@synopackageland/templates 0.9.8 → 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.
Files changed (133) hide show
  1. package/README.md +27 -27
  2. package/package.json +2 -2
  3. package/templates/_shared/AGENTS.md +53 -15
  4. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
  5. package/templates/native-go/backend/cmd/app/main.go +15 -1
  6. package/templates/native-go/backend/cmd/app/main_test.go +33 -0
  7. package/templates/native-go/backend/go.mod +1 -1
  8. package/templates/native-go/backend/go.sum +2 -2
  9. package/templates/native-go/i18n/glossary.json +3 -1
  10. package/templates/native-go/i18n/messages/de.json +6 -0
  11. package/templates/native-go/i18n/messages/en.json +6 -0
  12. package/templates/native-go/i18n/messages/es.json +6 -0
  13. package/templates/native-go/i18n/messages/fr.json +6 -0
  14. package/templates/native-go/i18n/messages/it.json +6 -0
  15. package/templates/native-go/i18n/messages/ja.json +6 -0
  16. package/templates/native-go/i18n/messages/ko.json +6 -0
  17. package/templates/native-go/i18n/messages/zh-CN.json +6 -0
  18. package/templates/native-go/i18n/messages/zh-TW.json +6 -0
  19. package/templates/native-go/ui/app.js +2 -1
  20. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +3 -2
  21. package/templates/native-go-jobs/frontend/test/background-page.test.ts +4 -4
  22. package/templates/native-go-vue/README.md +19 -7
  23. package/templates/native-go-vue/frontend/package-lock.json +9 -9
  24. package/templates/native-go-vue/frontend/package.json +2 -2
  25. package/templates/native-go-vue/frontend/src/App.vue +31 -36
  26. package/templates/native-go-vue/frontend/test/apps.test.ts +65 -12
  27. package/templates/native-go-vue/frontend/test/context.ts +8 -0
  28. package/templates/native-go-vue/frontend/test/native-controls.test.ts +3 -4
  29. package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +2 -2
  30. package/templates/native-node/README.md +153 -0
  31. package/templates/native-node/backend/app/package.json +4 -0
  32. package/templates/native-node/backend/app/permissions.json +5 -0
  33. package/templates/native-node/backend/package-lock.json +59 -0
  34. package/templates/native-node/backend/package.json +17 -0
  35. package/templates/native-node/backend/src/delegate.ts +5 -0
  36. package/templates/native-node/backend/src/jobs.ts +5 -0
  37. package/templates/native-node/backend/src/main.ts +4 -0
  38. package/templates/native-node/backend/src/params.ts +11 -0
  39. package/templates/native-node/backend/src/root-ops.ts +5 -0
  40. package/templates/native-node/backend/src/routes.ts +97 -0
  41. package/templates/native-node/backend/src/service.ts +22 -0
  42. package/templates/native-node/backend/test/routes.test.ts +75 -0
  43. package/templates/native-node/backend/test/support.ts +67 -0
  44. package/templates/native-node/backend/tsconfig.build.json +8 -0
  45. package/templates/native-node/backend/tsconfig.json +18 -0
  46. package/templates/native-node/dsm-package.yaml +80 -0
  47. package/templates/native-node/gitignore +4 -0
  48. package/templates/native-node-delegate/backend/src/delegate.ts +35 -0
  49. package/templates/native-node-delegate/backend/test/delegate.test.ts +51 -0
  50. package/templates/native-node-jobs/backend/src/jobs.ts +38 -0
  51. package/templates/native-node-jobs/backend/test/jobs.test.ts +73 -0
  52. package/templates/native-node-root-ops/backend/src/root-ops.ts +16 -0
  53. package/templates/native-node-root-ops/backend/test/root-ops.test.ts +23 -0
  54. package/templates/native-node-vue/README.md +56 -0
  55. package/templates/native-node-vue/gitignore +4 -0
  56. package/templates/native-python/README.md +150 -0
  57. package/templates/native-python/backend/app/delegate.py +5 -0
  58. package/templates/native-python/backend/app/jobs.py +5 -0
  59. package/templates/native-python/backend/app/main.py +5 -0
  60. package/templates/native-python/backend/app/params.py +10 -0
  61. package/templates/native-python/backend/app/permissions.json +5 -0
  62. package/templates/native-python/backend/app/root_ops.py +5 -0
  63. package/templates/native-python/backend/app/routes.py +107 -0
  64. package/templates/native-python/backend/tests/support.py +72 -0
  65. package/templates/native-python/backend/tests/test_routes.py +73 -0
  66. package/templates/native-python/dsm-package.yaml +77 -0
  67. package/templates/native-python/gitignore +2 -0
  68. package/templates/native-python-delegate/backend/app/delegate.py +42 -0
  69. package/templates/native-python-delegate/backend/tests/test_delegate.py +58 -0
  70. package/templates/native-python-jobs/backend/app/jobs.py +40 -0
  71. package/templates/native-python-jobs/backend/tests/test_jobs.py +72 -0
  72. package/templates/native-python-root-ops/backend/app/root_ops.py +18 -0
  73. package/templates/native-python-root-ops/backend/tests/test_root_ops.py +34 -0
  74. package/templates/native-python-vue/README.md +56 -0
  75. package/templates/native-python-vue/gitignore +3 -0
  76. package/templates/native-rust/README.md +74 -296
  77. package/templates/native-rust/backend/Cargo.lock +6 -6
  78. package/templates/native-rust/backend/Cargo.toml +2 -10
  79. package/templates/native-rust/backend/src/delegate.rs +6 -0
  80. package/templates/native-rust/backend/src/jobs.rs +6 -0
  81. package/templates/native-rust/backend/src/main.rs +12 -0
  82. package/templates/native-rust/backend/src/root_ops.rs +6 -0
  83. package/templates/native-rust/backend/src/routes.rs +352 -0
  84. package/templates/native-rust/backend/src/test_support.rs +91 -0
  85. package/templates/native-rust/dsm-package.yaml +10 -21
  86. package/templates/native-rust-delegate/backend/src/delegate.rs +171 -0
  87. package/templates/native-rust-jobs/backend/src/jobs.rs +240 -0
  88. package/templates/native-rust-root-ops/backend/src/root_ops.rs +83 -0
  89. package/templates/native-rust-vue/README.md +35 -34
  90. package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +0 -207
  91. package/templates/native-rust/backend/src/app/background/mod.rs +0 -424
  92. package/templates/native-rust/backend/src/app/background/owner.rs +0 -77
  93. package/templates/native-rust/backend/src/app/background/owner_tests.rs +0 -174
  94. package/templates/native-rust/backend/src/app/background/schedule.rs +0 -126
  95. package/templates/native-rust/backend/src/app/background/schedule_tests.rs +0 -108
  96. package/templates/native-rust/backend/src/app/background/tests.rs +0 -505
  97. package/templates/native-rust/backend/src/app/background/work.rs +0 -78
  98. package/templates/native-rust/backend/src/app/background/work_tests.rs +0 -123
  99. package/templates/native-rust/backend/src/app/background_routes.rs +0 -22
  100. package/templates/native-rust/backend/src/app/delegate_config.rs +0 -188
  101. package/templates/native-rust/backend/src/app/delegate_routes.rs +0 -22
  102. package/templates/native-rust/backend/src/app/identity.rs +0 -56
  103. package/templates/native-rust/backend/src/app/migrations.rs +0 -79
  104. package/templates/native-rust/backend/src/app/mod.rs +0 -30
  105. package/templates/native-rust/backend/src/app/portal.rs +0 -317
  106. package/templates/native-rust/backend/src/app/root_routes.rs +0 -23
  107. package/templates/native-rust/backend/src/app/routes.rs +0 -315
  108. package/templates/native-rust/backend/src/app/session_files.rs +0 -19
  109. package/templates/native-rust/backend/src/app/shared_folder.rs +0 -383
  110. package/templates/native-rust/backend/src/app/test_support.rs +0 -202
  111. package/templates/native-rust/backend/src/app/uninstall.rs +0 -38
  112. package/templates/native-rust/backend/src/bin/api.rs +0 -72
  113. package/templates/native-rust/backend/src/bin/background-worker.rs +0 -37
  114. package/templates/native-rust/backend/src/build_env.rs +0 -155
  115. package/templates/native-rust/backend/src/lib.rs +0 -6
  116. package/templates/native-rust/backend/src/permissions.rs +0 -54
  117. package/templates/native-rust/backend/src/process.rs +0 -91
  118. package/templates/native-rust/i18n/glossary.json +0 -663
  119. package/templates/native-rust/i18n/messages/de.json +0 -30
  120. package/templates/native-rust/i18n/messages/en.json +0 -30
  121. package/templates/native-rust/i18n/messages/es.json +0 -30
  122. package/templates/native-rust/i18n/messages/fr.json +0 -30
  123. package/templates/native-rust/i18n/messages/it.json +0 -30
  124. package/templates/native-rust/i18n/messages/ja.json +0 -30
  125. package/templates/native-rust/i18n/messages/ko.json +0 -30
  126. package/templates/native-rust/i18n/messages/zh-CN.json +0 -30
  127. package/templates/native-rust/i18n/messages/zh-TW.json +0 -30
  128. package/templates/native-rust/ui/app.css +0 -45
  129. package/templates/native-rust/ui/app.js +0 -111
  130. package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +0 -92
  131. package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +0 -52
  132. package/templates/native-rust-jobs/backend/src/app/background_routes.rs +0 -109
  133. 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,4 @@
1
+ dist/
2
+ node_modules/
3
+ backend/build/
4
+ backend/app/*.js
@@ -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,5 @@
1
+ """Saves to a user's destination: `synopkgland init --with delegate` replaces this stub with an example."""
2
+
3
+
4
+ def register(app, cfg):
5
+ pass
@@ -0,0 +1,5 @@
1
+ """Background jobs: `synopkgland init --with jobs` replaces this stub with an example."""
2
+
3
+
4
+ def register(app, cfg):
5
+ pass
@@ -0,0 +1,5 @@
1
+ """The App: the runtime starts it once per call (spec/bridge/v1)."""
2
+
3
+ import routes
4
+
5
+ routes.build(routes.Config.from_env()).run()
@@ -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,5 @@
1
+ {
2
+ "schema": 1,
3
+ "default": "administrators",
4
+ "methods": {}
5
+ }
@@ -0,0 +1,5 @@
1
+ """Root operations: `synopkgland init --with root-ops` replaces this stub with an example."""
2
+
3
+
4
+ def register(app, cfg):
5
+ pass
@@ -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,2 @@
1
+ dist/
2
+ __pycache__/
@@ -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)