@synopackageland/templates 0.9.9 → 0.10.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 (137) hide show
  1. package/README.md +29 -29
  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/messages/de.json +4 -0
  10. package/templates/native-go/i18n/messages/en.json +4 -0
  11. package/templates/native-go/i18n/messages/es.json +4 -0
  12. package/templates/native-go/i18n/messages/fr.json +4 -0
  13. package/templates/native-go/i18n/messages/it.json +4 -0
  14. package/templates/native-go/i18n/messages/ja.json +4 -0
  15. package/templates/native-go/i18n/messages/ko.json +4 -0
  16. package/templates/native-go/i18n/messages/zh-CN.json +4 -0
  17. package/templates/native-go/i18n/messages/zh-TW.json +4 -0
  18. package/templates/native-go/ui/app.js +2 -1
  19. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +24 -3
  20. package/templates/native-go-jobs/frontend/test/background-page.test.ts +4 -4
  21. package/templates/native-go-root-ops/backend/internal/app/executor.go +2 -65
  22. package/templates/native-go-root-ops/backend/internal/app/executor_test.go +10 -82
  23. package/templates/native-go-root-ops/frontend/src/ExecutorGate.vue +16 -0
  24. package/templates/native-go-root-ops/frontend/test/executor-gate.test.ts +60 -0
  25. package/templates/native-go-vue/README.md +24 -8
  26. package/templates/native-go-vue/frontend/package-lock.json +9 -9
  27. package/templates/native-go-vue/frontend/package.json +2 -2
  28. package/templates/native-go-vue/frontend/src/App.vue +36 -36
  29. package/templates/native-go-vue/frontend/src/ExecutorGate.vue +10 -0
  30. package/templates/native-go-vue/frontend/test/apps.test.ts +65 -12
  31. package/templates/native-go-vue/frontend/test/context.ts +8 -0
  32. package/templates/native-go-vue/frontend/test/native-controls.test.ts +3 -4
  33. package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +2 -2
  34. package/templates/native-node/README.md +153 -0
  35. package/templates/native-node/backend/app/package.json +4 -0
  36. package/templates/native-node/backend/app/permissions.json +5 -0
  37. package/templates/native-node/backend/package-lock.json +59 -0
  38. package/templates/native-node/backend/package.json +17 -0
  39. package/templates/native-node/backend/src/delegate.ts +5 -0
  40. package/templates/native-node/backend/src/jobs.ts +5 -0
  41. package/templates/native-node/backend/src/main.ts +4 -0
  42. package/templates/native-node/backend/src/params.ts +11 -0
  43. package/templates/native-node/backend/src/root-ops.ts +5 -0
  44. package/templates/native-node/backend/src/routes.ts +97 -0
  45. package/templates/native-node/backend/src/service.ts +22 -0
  46. package/templates/native-node/backend/test/routes.test.ts +75 -0
  47. package/templates/native-node/backend/test/support.ts +67 -0
  48. package/templates/native-node/backend/tsconfig.build.json +8 -0
  49. package/templates/native-node/backend/tsconfig.json +18 -0
  50. package/templates/native-node/dsm-package.yaml +80 -0
  51. package/templates/native-node/gitignore +4 -0
  52. package/templates/native-node-delegate/backend/src/delegate.ts +35 -0
  53. package/templates/native-node-delegate/backend/test/delegate.test.ts +51 -0
  54. package/templates/native-node-jobs/backend/src/jobs.ts +38 -0
  55. package/templates/native-node-jobs/backend/test/jobs.test.ts +73 -0
  56. package/templates/native-node-root-ops/backend/src/root-ops.ts +16 -0
  57. package/templates/native-node-root-ops/backend/test/root-ops.test.ts +23 -0
  58. package/templates/native-node-vue/README.md +56 -0
  59. package/templates/native-node-vue/gitignore +4 -0
  60. package/templates/native-python/README.md +150 -0
  61. package/templates/native-python/backend/app/delegate.py +5 -0
  62. package/templates/native-python/backend/app/jobs.py +5 -0
  63. package/templates/native-python/backend/app/main.py +5 -0
  64. package/templates/native-python/backend/app/params.py +10 -0
  65. package/templates/native-python/backend/app/permissions.json +5 -0
  66. package/templates/native-python/backend/app/root_ops.py +5 -0
  67. package/templates/native-python/backend/app/routes.py +107 -0
  68. package/templates/native-python/backend/tests/support.py +72 -0
  69. package/templates/native-python/backend/tests/test_routes.py +73 -0
  70. package/templates/native-python/dsm-package.yaml +77 -0
  71. package/templates/native-python/gitignore +2 -0
  72. package/templates/native-python-delegate/backend/app/delegate.py +42 -0
  73. package/templates/native-python-delegate/backend/tests/test_delegate.py +58 -0
  74. package/templates/native-python-jobs/backend/app/jobs.py +40 -0
  75. package/templates/native-python-jobs/backend/tests/test_jobs.py +72 -0
  76. package/templates/native-python-root-ops/backend/app/root_ops.py +18 -0
  77. package/templates/native-python-root-ops/backend/tests/test_root_ops.py +34 -0
  78. package/templates/native-python-vue/README.md +56 -0
  79. package/templates/native-python-vue/gitignore +3 -0
  80. package/templates/native-rust/README.md +74 -296
  81. package/templates/native-rust/backend/Cargo.lock +6 -6
  82. package/templates/native-rust/backend/Cargo.toml +2 -10
  83. package/templates/native-rust/backend/src/delegate.rs +6 -0
  84. package/templates/native-rust/backend/src/jobs.rs +6 -0
  85. package/templates/native-rust/backend/src/main.rs +12 -0
  86. package/templates/native-rust/backend/src/root_ops.rs +6 -0
  87. package/templates/native-rust/backend/src/routes.rs +352 -0
  88. package/templates/native-rust/backend/src/test_support.rs +91 -0
  89. package/templates/native-rust/dsm-package.yaml +10 -21
  90. package/templates/native-rust-delegate/backend/src/delegate.rs +171 -0
  91. package/templates/native-rust-jobs/backend/src/jobs.rs +240 -0
  92. package/templates/native-rust-root-ops/backend/src/root_ops.rs +83 -0
  93. package/templates/native-rust-vue/README.md +35 -34
  94. package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +0 -207
  95. package/templates/native-rust/backend/src/app/background/mod.rs +0 -424
  96. package/templates/native-rust/backend/src/app/background/owner.rs +0 -77
  97. package/templates/native-rust/backend/src/app/background/owner_tests.rs +0 -174
  98. package/templates/native-rust/backend/src/app/background/schedule.rs +0 -126
  99. package/templates/native-rust/backend/src/app/background/schedule_tests.rs +0 -108
  100. package/templates/native-rust/backend/src/app/background/tests.rs +0 -505
  101. package/templates/native-rust/backend/src/app/background/work.rs +0 -78
  102. package/templates/native-rust/backend/src/app/background/work_tests.rs +0 -123
  103. package/templates/native-rust/backend/src/app/background_routes.rs +0 -22
  104. package/templates/native-rust/backend/src/app/delegate_config.rs +0 -188
  105. package/templates/native-rust/backend/src/app/delegate_routes.rs +0 -22
  106. package/templates/native-rust/backend/src/app/identity.rs +0 -56
  107. package/templates/native-rust/backend/src/app/migrations.rs +0 -79
  108. package/templates/native-rust/backend/src/app/mod.rs +0 -30
  109. package/templates/native-rust/backend/src/app/portal.rs +0 -317
  110. package/templates/native-rust/backend/src/app/root_routes.rs +0 -23
  111. package/templates/native-rust/backend/src/app/routes.rs +0 -315
  112. package/templates/native-rust/backend/src/app/session_files.rs +0 -19
  113. package/templates/native-rust/backend/src/app/shared_folder.rs +0 -383
  114. package/templates/native-rust/backend/src/app/test_support.rs +0 -202
  115. package/templates/native-rust/backend/src/app/uninstall.rs +0 -38
  116. package/templates/native-rust/backend/src/bin/api.rs +0 -72
  117. package/templates/native-rust/backend/src/bin/background-worker.rs +0 -37
  118. package/templates/native-rust/backend/src/build_env.rs +0 -155
  119. package/templates/native-rust/backend/src/lib.rs +0 -6
  120. package/templates/native-rust/backend/src/permissions.rs +0 -54
  121. package/templates/native-rust/backend/src/process.rs +0 -91
  122. package/templates/native-rust/i18n/glossary.json +0 -665
  123. package/templates/native-rust/i18n/messages/de.json +0 -32
  124. package/templates/native-rust/i18n/messages/en.json +0 -32
  125. package/templates/native-rust/i18n/messages/es.json +0 -32
  126. package/templates/native-rust/i18n/messages/fr.json +0 -32
  127. package/templates/native-rust/i18n/messages/it.json +0 -32
  128. package/templates/native-rust/i18n/messages/ja.json +0 -32
  129. package/templates/native-rust/i18n/messages/ko.json +0 -32
  130. package/templates/native-rust/i18n/messages/zh-CN.json +0 -32
  131. package/templates/native-rust/i18n/messages/zh-TW.json +0 -32
  132. package/templates/native-rust/ui/app.css +0 -45
  133. package/templates/native-rust/ui/app.js +0 -111
  134. package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +0 -92
  135. package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +0 -52
  136. package/templates/native-rust-jobs/backend/src/app/background_routes.rs +0 -109
  137. package/templates/native-rust-root-ops/backend/src/app/root_routes.rs +0 -475
@@ -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.1
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)
@@ -0,0 +1,58 @@
1
+ import os
2
+ import stat
3
+ import tempfile
4
+ import unittest
5
+
6
+ from support import Error, call
7
+
8
+ import routes
9
+
10
+ SAVED = {"state": "queued"}
11
+
12
+
13
+ class DelegateTest(unittest.TestCase):
14
+ def setUp(self):
15
+ self.dir = tempfile.TemporaryDirectory()
16
+ self.addCleanup(self.dir.cleanup)
17
+ self.staging = os.path.join(self.dir.name, "executor-staging")
18
+ self.app = routes.build(routes.Config("ExampleApp", self.dir.name))
19
+
20
+ def save(self, job_id="job-1", user="alice", move=lambda p: SAVED):
21
+ params = {"delegation_id": "d1", "job_id": job_id}
22
+ return call(self.app, "report.save", params, user=user, admin=False, services={"delegate.move_from_staging": move})
23
+
24
+ def test_save_moves_a_private_report_to_the_callers_destination(self):
25
+ reply = self.save()
26
+ self.assertEqual(reply.data, SAVED)
27
+ path = os.path.join(self.staging, "report-job-1.txt")
28
+ with open(path, encoding="utf-8") as f:
29
+ self.assertIn("alice", f.read())
30
+ self.assertEqual(stat.S_IMODE(os.stat(path).st_mode), 0o600)
31
+ move = {"delegation_id": "d1", "job_id": "job-1", "staging": "report-job-1.txt", "name": "report-job-1.txt", "size": os.path.getsize(path)}
32
+ self.assertEqual(reply.services, [("delegate.move_from_staging", move)])
33
+
34
+ def test_a_repeated_save_sends_the_same_file_again(self):
35
+ first = self.save().services
36
+ path = os.path.join(self.staging, "report-job-1.txt")
37
+ with open(path, encoding="utf-8") as f:
38
+ report = f.read()
39
+ self.assertEqual(self.save(user="bob").services, first)
40
+ with open(path, encoding="utf-8") as f:
41
+ self.assertEqual(f.read(), report)
42
+
43
+ def test_save_passes_on_the_executors_refusal(self):
44
+ def denied(params):
45
+ raise Error("DELEGATION_DENIED", "The destination is no longer allowed.")
46
+
47
+ self.assertEqual(self.save(move=denied).error["code"], "DELEGATION_DENIED")
48
+
49
+ def test_save_refuses_invalid_params_before_writing(self):
50
+ for params in [None, {"delegation_id": "d1"}, {"delegation_id": "d1", "job_id": "../x"}, {"delegation_id": "d1", "job_id": ""}, {"delegation_id": 1, "job_id": "j"}]:
51
+ with self.subTest(params=params):
52
+ reply = call(self.app, "report.save", params)
53
+ self.assertEqual((reply.error["code"], reply.services), ("bad_request", []))
54
+ self.assertFalse(os.path.exists(self.staging))
55
+
56
+
57
+ if __name__ == "__main__":
58
+ unittest.main()
@@ -0,0 +1,40 @@
1
+ """Background jobs: hash a NAS file the caller may read.
2
+
3
+ The SDK's worker runs each job as the package account: it hashes the job's
4
+ input (SHA-256) and keeps the result for the job's owner, the caller. The
5
+ runtime serves files.read with the caller's DSM session and bg.* for the
6
+ caller's own jobs only.
7
+ """
8
+
9
+ import os
10
+ import shutil
11
+ import tempfile
12
+
13
+ from synopkgland_app import Error
14
+
15
+ from params import fields
16
+
17
+ # A job's input is at most 512 KiB, copied into the job when it is submitted.
18
+ MAX_INPUT = 512 * 1024
19
+
20
+
21
+ def register(app, cfg):
22
+ app.method("hash.submit")(lambda ctx, params: submit(cfg, ctx, params))
23
+ app.method("hash.get")(lambda ctx, params: ctx.runtime("bg.get", fields(params, id=str)))
24
+ app.method("hash.cancel")(lambda ctx, params: ctx.runtime("bg.cancel", fields(params, id=str)))
25
+
26
+
27
+ def submit(cfg, ctx, params):
28
+ p = fields(params, key=str, path=str)
29
+ read = ctx.runtime("files.read", {"path": p["path"]})
30
+ if read["size"] > MAX_INPUT:
31
+ raise Error("limit_exceeded", "The file is larger than allowed.")
32
+ # bg.submit takes its input from the data directory; the call's spool is elsewhere.
33
+ inputs = os.path.join(cfg.data_dir, "hash-inputs")
34
+ os.makedirs(inputs, mode=0o700, exist_ok=True)
35
+ with open(read["path"], "rb") as source, tempfile.NamedTemporaryFile(dir=inputs, delete=False) as copy:
36
+ shutil.copyfileobj(source, copy)
37
+ try:
38
+ return ctx.runtime("bg.submit", {"key": p["key"], "name": os.path.basename(p["path"]), "input": copy.name})
39
+ finally:
40
+ os.unlink(copy.name)
@@ -0,0 +1,72 @@
1
+ import os
2
+ import tempfile
3
+ import unittest
4
+
5
+ from support import Error, call
6
+
7
+ import routes
8
+
9
+ JOB = {"job": {"id": "j1", "state": "queued"}, "duplicate": False}
10
+
11
+
12
+ class JobsTest(unittest.TestCase):
13
+ def setUp(self):
14
+ self.dir = tempfile.TemporaryDirectory()
15
+ self.addCleanup(self.dir.cleanup)
16
+ self.data = os.path.join(self.dir.name, "var")
17
+ os.mkdir(self.data)
18
+ self.app = routes.build(routes.Config("ExampleApp", self.data))
19
+ self.submitted = []
20
+
21
+ def spool(self, content):
22
+ path = os.path.join(self.dir.name, "spool")
23
+ with open(path, "wb") as f:
24
+ f.write(content)
25
+ return {"path": path, "size": len(content)}
26
+
27
+ def submit(self, params):
28
+ # The runtime reads the input file while it answers bg.submit.
29
+ with open(params["input"], "rb") as f:
30
+ self.submitted.append(dict(params, content=f.read()))
31
+ return JOB
32
+
33
+ def test_submit_hashes_a_copy_of_the_callers_file(self):
34
+ services = {"files.read": lambda p: self.spool(b"hello"), "bg.submit": self.submit}
35
+ reply = call(self.app, "hash.submit", {"key": "k1", "path": "/home/report.txt"}, services=services)
36
+ self.assertEqual(reply.data, JOB)
37
+ self.assertEqual([op for op, _ in reply.services], ["files.read", "bg.submit"])
38
+ self.assertEqual(reply.services[0][1], {"path": "/home/report.txt"})
39
+ [submitted] = self.submitted
40
+ self.assertEqual((submitted["key"], submitted["name"], submitted["content"]), ("k1", "report.txt", b"hello"))
41
+ self.assertTrue(submitted["input"].startswith(self.data + os.sep))
42
+ self.assertFalse(os.path.exists(submitted["input"]), "the copy is removed once submitted")
43
+
44
+ def test_submit_refuses_a_file_over_the_job_input_limit(self):
45
+ services = {"files.read": lambda p: self.spool(b"x" * (512 * 1024 + 1)), "bg.submit": self.submit}
46
+ reply = call(self.app, "hash.submit", {"key": "k1", "path": "/home/big.bin"}, services=services)
47
+ self.assertEqual(reply.error["code"], "limit_exceeded")
48
+ self.assertEqual(self.submitted, [])
49
+
50
+ def test_submit_passes_on_the_runtimes_refusal(self):
51
+ def unreadable(params):
52
+ raise Error("read_failed", "Unable to read the file.")
53
+
54
+ reply = call(self.app, "hash.submit", {"key": "k1", "path": "/home/a"}, services={"files.read": unreadable})
55
+ self.assertEqual(reply.error["code"], "read_failed")
56
+
57
+ def test_submit_refuses_invalid_params_before_any_service(self):
58
+ for params in [None, {"key": "k1"}, {"key": 1, "path": "/home/a"}, {"key": "k1", "path": "/home/a", "x": 1}]:
59
+ with self.subTest(params=params):
60
+ reply = call(self.app, "hash.submit", params)
61
+ self.assertEqual((reply.error["code"], reply.services), ("bad_request", []))
62
+
63
+ def test_get_and_cancel_act_on_the_callers_job(self):
64
+ for method, op in [("hash.get", "bg.get"), ("hash.cancel", "bg.cancel")]:
65
+ with self.subTest(method=method):
66
+ reply = call(self.app, method, {"id": "j1"}, services={op: lambda p: JOB["job"]})
67
+ self.assertEqual((reply.data, reply.services), (JOB["job"], [(op, {"id": "j1"})]))
68
+ self.assertEqual(call(self.app, method, {}).error["code"], "bad_request")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ unittest.main()
@@ -0,0 +1,18 @@
1
+ """Root operations: run root/whoami.sh as root.
2
+
3
+ The executor runs only the root programs an administrator approved, and
4
+ checks the caller's DSM session and Application Privileges itself; the App
5
+ names the program and never runs anything as root.
6
+ """
7
+
8
+ from synopkgland_app import Error
9
+
10
+
11
+ def register(app, cfg):
12
+ app.method("whoami.run")(lambda ctx, params: whoami(ctx, params))
13
+
14
+
15
+ def whoami(ctx, params):
16
+ if params not in (None, {}):
17
+ raise Error.bad_request("whoami.run takes no params")
18
+ return ctx.runtime("root.run", {"name": "whoami"})
@@ -0,0 +1,34 @@
1
+ import tempfile
2
+ import unittest
3
+
4
+ from support import Error, call
5
+
6
+ import routes
7
+
8
+
9
+ class RootOpsTest(unittest.TestCase):
10
+ def setUp(self):
11
+ self.dir = tempfile.TemporaryDirectory()
12
+ self.addCleanup(self.dir.cleanup)
13
+ self.app = routes.build(routes.Config("ExampleApp", self.dir.name))
14
+
15
+ def test_whoami_runs_the_approved_root_program(self):
16
+ output = {"exit_code": 0, "stdout": "0\n", "stderr": ""}
17
+ for params in [None, {}]:
18
+ with self.subTest(params=params):
19
+ reply = call(self.app, "whoami.run", params, services={"root.run": lambda p: output})
20
+ self.assertEqual((reply.data, reply.services), (output, [("root.run", {"name": "whoami"})]))
21
+
22
+ def test_whoami_passes_on_the_executors_refusal(self):
23
+ def paused(params):
24
+ raise Error("EXECUTOR_PAUSED", "The executor is paused.")
25
+
26
+ reply = call(self.app, "whoami.run", {}, services={"root.run": paused})
27
+ self.assertEqual(reply.error["code"], "EXECUTOR_PAUSED")
28
+
29
+ def test_whoami_takes_no_params(self):
30
+ self.assertEqual(call(self.app, "whoami.run", {"name": "other"}).error["code"], "bad_request")
31
+
32
+
33
+ if __name__ == "__main__":
34
+ unittest.main()
@@ -0,0 +1,56 @@
1
+ # Native Python 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 Python 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,3 @@
1
+ dist/
2
+ __pycache__/
3
+ node_modules/