@synopackageland/templates 0.10.0 → 0.10.2
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 +4 -4
- package/package.json +1 -1
- package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
- package/templates/native-go/backend/go.mod +1 -1
- package/templates/native-go/backend/go.sum +2 -2
- package/templates/native-go/i18n/glossary.json +0 -1
- package/templates/native-go/i18n/messages/de.json +1 -1
- package/templates/native-go/i18n/messages/en.json +1 -1
- package/templates/native-go/i18n/messages/es.json +1 -1
- package/templates/native-go/i18n/messages/fr.json +1 -1
- package/templates/native-go/i18n/messages/it.json +1 -1
- package/templates/native-go/i18n/messages/ja.json +1 -1
- package/templates/native-go/i18n/messages/ko.json +1 -1
- package/templates/native-go/i18n/messages/zh-CN.json +2 -2
- package/templates/native-go/i18n/messages/zh-TW.json +1 -1
- package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +21 -1
- package/templates/native-go-root-ops/backend/internal/app/executor.go +2 -65
- package/templates/native-go-root-ops/backend/internal/app/executor_test.go +10 -82
- package/templates/native-go-root-ops/frontend/src/ExecutorGate.vue +16 -0
- package/templates/native-go-root-ops/frontend/test/executor-gate.test.ts +60 -0
- package/templates/native-go-vue/README.md +5 -1
- package/templates/native-go-vue/frontend/package-lock.json +4 -4
- package/templates/native-go-vue/frontend/package.json +1 -1
- package/templates/native-go-vue/frontend/src/App.vue +6 -1
- package/templates/native-go-vue/frontend/src/ExecutorGate.vue +10 -0
- package/templates/native-node/dsm-package.yaml +1 -1
- package/templates/native-python/dsm-package.yaml +1 -1
- package/templates/native-rust/backend/Cargo.lock +2 -2
- package/templates/native-rust/backend/Cargo.toml +1 -1
package/README.md
CHANGED
|
@@ -7,10 +7,10 @@ the files an App owns; the SDK (`github.com/synopackageland/syno-pkg-land-sdk`,
|
|
|
7
7
|
| Template | What it adds |
|
|
8
8
|
| --- | --- |
|
|
9
9
|
| `native-go` | Base: `dsm-package.yaml`, Go module (`backend/cmd/{app,background-worker}`, `backend/internal/app`, `backend/internal/api/permissions.json`), plain `ui/`, i18n |
|
|
10
|
-
| `native-go-vue` | Vue shell in `frontend/` (`App.vue`, `main.ts`, app i18n, `CapabilityPanels.vue`) |
|
|
10
|
+
| `native-go-vue` | Vue shell in `frontend/` (`App.vue`, `main.ts`, app i18n, `CapabilityPanels.vue`, `ExecutorGate.vue`) |
|
|
11
11
|
| `native-go-jobs` | Background job routes in `internal/app` and their page test |
|
|
12
12
|
| `native-go-delegate` | Delegate routes, the delegate background worker, the delegate `CapabilityPanels.vue` |
|
|
13
|
-
| `native-go-root-ops` |
|
|
13
|
+
| `native-go-root-ops` | The SDK's executor routes and `root.run` in `internal/app`, an example `root/whoami.sh`, and the Vue `ExecutorGate.vue` that holds the pages until the executor is approved |
|
|
14
14
|
| `native-python` | Base: `dsm-package.yaml` (`backend.runtime: python`), the App in `backend/app` (`routes.py`, stub capability modules, `permissions.json`), unittest tests |
|
|
15
15
|
| `native-node` | Base: `dsm-package.yaml` (`backend.runtime: node`), TypeScript in `backend/src` compiled into `backend/app`, node:test tests, `package-lock.json` |
|
|
16
16
|
| `native-rust` | Base: `dsm-package.yaml` (`backend.runtime: rust`, `methods`), the Cargo project in `backend` on the `syno-pkg-land-app` crate, `Cargo.lock`, `rust-toolchain.toml` |
|
|
@@ -34,11 +34,11 @@ package.json without devDependencies and scripts, and `dist/`).
|
|
|
34
34
|
The placeholders `__PACKAGE__` (also the Go module path), `__DISPLAY_NAME__` and
|
|
35
35
|
`__BACKGROUND_ENABLED__` are substituted by `init`.
|
|
36
36
|
|
|
37
|
-
`backend/go.mod` requires `github.com/synopackageland/syno-pkg-land-sdk v0.10.
|
|
37
|
+
`backend/go.mod` requires `github.com/synopackageland/syno-pkg-land-sdk v0.10.2`; that line is
|
|
38
38
|
the single source of the SDK version and `frontend/package.json` pins
|
|
39
39
|
`@synopackageland/sdk` to the same version. Python and Node.js name it in
|
|
40
40
|
`backend.sdk`, Rust in the `syno-pkg-land-app` tag of `backend/Cargo.toml`
|
|
41
|
-
(and `Cargo.lock`); all of them pin 0.10.
|
|
41
|
+
(and `Cargo.lock`); all of them pin 0.10.2.
|
|
42
42
|
|
|
43
43
|
## Notice writer
|
|
44
44
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/templates",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "Project templates for synopkgland init: native-go, native-python, native-node and native-rust (plain or Vue, with jobs, delegate and root-ops overlays), binary and compose.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {"type": "git", "url": "git+https://github.com/synopackageland/syno-pkg-land-templates.git"},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
github.com/synopackageland/syno-pkg-land-sdk v0.10.
|
|
2
|
-
github.com/synopackageland/syno-pkg-land-sdk v0.10.
|
|
1
|
+
github.com/synopackageland/syno-pkg-land-sdk v0.10.2 h1:2sQTuKCkwbFk5vnnY+21N3xIBKu7D8PVydgwxmIeGWI=
|
|
2
|
+
github.com/synopackageland/syno-pkg-land-sdk v0.10.2/go.mod h1:hlsvaVuH2wsAoR8jzI1Rg/NeejQSFpAaN6zpIWsVbjI=
|
|
3
3
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
|
4
4
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"error.method_not_allowed": "Dieser Vorgang kann nicht ausgeführt werden. Bitte versuchen Sie es später erneut.",
|
|
15
15
|
"error.network": "Verbindung fehlgeschlagen. Bitte überprüfen Sie Ihre Netzwerkeinstellungen.",
|
|
16
16
|
"error.request_too_large": "Dieser Vorgang kann nicht ausgeführt werden. Bitte versuchen Sie es später erneut.",
|
|
17
|
-
"error.request_timeout": "
|
|
17
|
+
"error.request_timeout": "Die Anfrage hat zu lange gedauert und wurde abgebrochen. Versuchen Sie es erneut oder mit einer kleineren Aufgabe.",
|
|
18
18
|
"error.request_canceled": "Dieser Vorgang kann nicht ausgeführt werden. Bitte versuchen Sie es später erneut.",
|
|
19
19
|
"error.session_required": "Verbindung abgelaufen. Melden Sie sich erneut an.",
|
|
20
20
|
"error.unknown_method": "Dieser Vorgang kann nicht ausgeführt werden. Bitte versuchen Sie es später erneut.",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"error.method_not_allowed": "Unable to perform this operation. Please try again later.",
|
|
15
15
|
"error.network": "Connection failed. Please check your network settings.",
|
|
16
16
|
"error.request_too_large": "Unable to perform this operation. Please try again later.",
|
|
17
|
-
"error.request_timeout": "
|
|
17
|
+
"error.request_timeout": "The request took too long and was stopped. Try again, or try a smaller task.",
|
|
18
18
|
"error.request_canceled": "Unable to perform this operation. Please try again later.",
|
|
19
19
|
"error.session_required": "Connection expired. Please sign in again.",
|
|
20
20
|
"error.unknown_method": "Unable to perform this operation. Please try again later.",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"error.method_not_allowed": "No se puede realizar esta operación. Vuelva a intentarlo más tarde.",
|
|
15
15
|
"error.network": "Error de la conexión. Compruebe su conexión de red.",
|
|
16
16
|
"error.request_too_large": "No se puede realizar esta operación. Vuelva a intentarlo más tarde.",
|
|
17
|
-
"error.request_timeout": "
|
|
17
|
+
"error.request_timeout": "La solicitud tardó demasiado y se detuvo. Vuelva a intentarlo o pruebe con una tarea más pequeña.",
|
|
18
18
|
"error.request_canceled": "No se puede realizar esta operación. Vuelva a intentarlo más tarde.",
|
|
19
19
|
"error.session_required": "Conexión caducada. Vuelva a conectarse.",
|
|
20
20
|
"error.unknown_method": "No se puede realizar esta operación. Vuelva a intentarlo más tarde.",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"error.method_not_allowed": "Impossible d'effectuer cette opération. Veuillez réessayer ultérieurement.",
|
|
15
15
|
"error.network": "La connexion a échoué. Vérifiez vos paramètres réseau.",
|
|
16
16
|
"error.request_too_large": "Impossible d'effectuer cette opération. Veuillez réessayer ultérieurement.",
|
|
17
|
-
"error.request_timeout": "
|
|
17
|
+
"error.request_timeout": "La requête a pris trop de temps et a été arrêtée. Réessayez ou essayez une tâche plus petite.",
|
|
18
18
|
"error.request_canceled": "Impossible d'effectuer cette opération. Veuillez réessayer ultérieurement.",
|
|
19
19
|
"error.session_required": "La connexion a expiré. Reconnectez-vous.",
|
|
20
20
|
"error.unknown_method": "Impossible d'effectuer cette opération. Veuillez réessayer ultérieurement.",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"error.method_not_allowed": "Impossibile eseguire questa operazione. Riprovare più tardi.",
|
|
15
15
|
"error.network": "Connessione non riuscita. Controllare le impostazioni di rete.",
|
|
16
16
|
"error.request_too_large": "Impossibile eseguire questa operazione. Riprovare più tardi.",
|
|
17
|
-
"error.request_timeout": "
|
|
17
|
+
"error.request_timeout": "La richiesta ha impiegato troppo tempo ed è stata interrotta. Riprovare o provare con un’attività più piccola.",
|
|
18
18
|
"error.request_canceled": "Impossibile eseguire questa operazione. Riprovare più tardi.",
|
|
19
19
|
"error.session_required": "Connessione scaduta. Accedere nuovamente.",
|
|
20
20
|
"error.unknown_method": "Impossibile eseguire questa operazione. Riprovare più tardi.",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"error.method_not_allowed": "この操作は実行できません。後でもう一度お試しください。",
|
|
15
15
|
"error.network": "接続に失敗しました。ネットワーク設定を確認してください。",
|
|
16
16
|
"error.request_too_large": "この操作は実行できません。後でもう一度お試しください。",
|
|
17
|
-
"error.request_timeout": "
|
|
17
|
+
"error.request_timeout": "リクエストの処理に時間がかかりすぎたため、停止しました。もう一度お試しいただくか、範囲を小さくしてください。",
|
|
18
18
|
"error.request_canceled": "この操作は実行できません。後でもう一度お試しください。",
|
|
19
19
|
"error.session_required": "接続の期限が切れています。もう 1 度サイン インしてください。",
|
|
20
20
|
"error.unknown_method": "この操作は実行できません。後でもう一度お試しください。",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"error.method_not_allowed": "이 작업을 수행할 수 없습니다. 나중에 다시 시도하십시오.",
|
|
15
15
|
"error.network": "연결 실패. 네트워크 설정을 확인해 주십시오.",
|
|
16
16
|
"error.request_too_large": "이 작업을 수행할 수 없습니다. 나중에 다시 시도하십시오.",
|
|
17
|
-
"error.request_timeout": "
|
|
17
|
+
"error.request_timeout": "요청 처리 시간이 너무 오래 걸려 중지되었습니다. 다시 시도하거나 작업 범위를 줄이십시오.",
|
|
18
18
|
"error.request_canceled": "이 작업을 수행할 수 없습니다. 나중에 다시 시도하십시오.",
|
|
19
19
|
"error.session_required": "연결 만료됨. 다시 로그인 해 주십시오.",
|
|
20
20
|
"error.unknown_method": "이 작업을 수행할 수 없습니다. 나중에 다시 시도하십시오.",
|
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
"error.method_not_allowed": "无法执行此操作。请稍后再试。",
|
|
15
15
|
"error.network": "无法联机,请检查你的网络设置。",
|
|
16
16
|
"error.request_too_large": "无法执行此操作。请稍后再试。",
|
|
17
|
-
"error.request_timeout": "
|
|
17
|
+
"error.request_timeout": "请求处理时间过长,已停止。请重试,或缩小范围后再试。",
|
|
18
18
|
"error.request_canceled": "无法执行此操作。请稍后再试。",
|
|
19
19
|
"error.session_required": "联机超时,请重新登录。",
|
|
20
20
|
"error.unknown_method": "无法执行此操作。请稍后再试。",
|
|
21
21
|
"error.app_unavailable": "App 当前不可用,请稍后重试。",
|
|
22
22
|
"error.limit_exceeded": "文件超过允许的大小。",
|
|
23
23
|
"error.read_failed": "无法读取文件。请稍后再试。",
|
|
24
|
-
"error.identity_unavailable": "
|
|
24
|
+
"error.identity_unavailable": "无法识别此用户账号。请稍后再试。",
|
|
25
25
|
"greeting.adminOnly": "只有 administrators 群组的成员可以更改问候语。",
|
|
26
26
|
"greeting.label": "新的问候语",
|
|
27
27
|
"greeting.save": "保存",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"error.method_not_allowed": "無法執行此操作。請稍後再試。",
|
|
15
15
|
"error.network": "無法連線,請檢查你的網路設定。",
|
|
16
16
|
"error.request_too_large": "無法執行此操作。請稍後再試。",
|
|
17
|
-
"error.request_timeout": "
|
|
17
|
+
"error.request_timeout": "請求處理太久已停止,請再試一次或縮小範圍。",
|
|
18
18
|
"error.request_canceled": "無法執行此操作。請稍後再試。",
|
|
19
19
|
"error.session_required": "連線逾時。請重新登入。",
|
|
20
20
|
"error.unknown_method": "無法執行此操作。請稍後再試。",
|
|
@@ -5,6 +5,7 @@ import {AppRoot} from '@synopackageland/ui'
|
|
|
5
5
|
import type {BackgroundSaveFacade} from '@synopackageland/sdk'
|
|
6
6
|
import CapabilityPanels from '../src/CapabilityPanels.vue'
|
|
7
7
|
import {mount} from '../src/main'
|
|
8
|
+
import {holdsPages} from '../src/ExecutorGate.vue'
|
|
8
9
|
import {dsmWindow} from './context'
|
|
9
10
|
const cleanup:Array<()=>void>=[]
|
|
10
11
|
afterEach(()=>cleanup.splice(0).forEach(fn=>fn()))
|
|
@@ -76,8 +77,9 @@ test('the app mount forwards ctx.executor to administrator setup only',async()=>
|
|
|
76
77
|
password.closest('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));await settle()
|
|
77
78
|
expect(authorize).toHaveBeenCalledWith('one-time')
|
|
78
79
|
}else{
|
|
79
|
-
|
|
80
|
+
// With root-ops the App's pages also wait for the executor, which reads its status.
|
|
80
81
|
expect(root.querySelector('input[type=password]')).toBeNull()
|
|
82
|
+
expect(authorize).not.toHaveBeenCalled()
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
})
|
|
@@ -103,3 +105,21 @@ test('the panels show a skeleton while the delegate status loads',async()=>{
|
|
|
103
105
|
const root=render({call,locale:'en'});await settle()
|
|
104
106
|
expect(root.querySelector('[data-ui-skeleton-line]')).not.toBeNull()
|
|
105
107
|
})
|
|
108
|
+
// AppShell's ExecutorNotice: a delegate App's pages stay while the executor waits for approval.
|
|
109
|
+
// With root-ops, ExecutorGate holds the pages instead (its own test).
|
|
110
|
+
test.skipIf(holdsPages)('an executor awaiting approval adds a notice above the pages and holds none of them back',async()=>{
|
|
111
|
+
const status=vi.fn(async()=>({state:'needs-admin-approval' as const}))
|
|
112
|
+
const call=vi.fn(async(method:string)=>{
|
|
113
|
+
if(method==='app.status')return {package:'DelegateApp',version:'1',user:'admin',admin:true}
|
|
114
|
+
if(method==='greeting.get')return {text:'Hello'}
|
|
115
|
+
if(method==='delegate.status')return {state:'needs-admin-approval'}
|
|
116
|
+
if(method==='delegate.list')return {delegations:[]}
|
|
117
|
+
throw {code:'unknown_method'}
|
|
118
|
+
})
|
|
119
|
+
const root=document.createElement('div');document.body.append(root)
|
|
120
|
+
const app=mount(root,dsmWindow({call,user:{name:'admin',isAdmin:true},executor:{status,authorize:vi.fn(),control:vi.fn()} as never}))
|
|
121
|
+
cleanup.push(()=>{app.unmount();root.remove()});await settle()
|
|
122
|
+
expect(root.textContent).toContain('Some features are unavailable until you approve system access for this app.')
|
|
123
|
+
expect(root.textContent).toContain('Package status')
|
|
124
|
+
expect((root.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Hello')
|
|
125
|
+
})
|
|
@@ -5,79 +5,16 @@ import (
|
|
|
5
5
|
"encoding/json"
|
|
6
6
|
"errors"
|
|
7
7
|
"net/http"
|
|
8
|
-
"os"
|
|
9
8
|
"path/filepath"
|
|
10
|
-
"strings"
|
|
11
9
|
"time"
|
|
12
10
|
|
|
13
11
|
"github.com/synopackageland/syno-pkg-land-sdk/api"
|
|
14
12
|
"github.com/synopackageland/syno-pkg-land-sdk/delegate"
|
|
15
13
|
)
|
|
16
14
|
|
|
15
|
+
// executor.status and executor.control are the SDK's, as the bridge runtime serves them.
|
|
17
16
|
func (cfg Config) rootRoutes() []api.Route {
|
|
18
|
-
return
|
|
19
|
-
api.Handle("executor.control", cfg.executorControl),
|
|
20
|
-
api.Handle("executor.status", cfg.executorStatus),
|
|
21
|
-
api.HandleRequest("root.run", cfg.executorRootRun),
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// API permissions default to administrators for unlisted methods. Check here
|
|
26
|
-
// too: control mutates the fixed package-owned control file before DSM run.
|
|
27
|
-
func (cfg Config) executorControl(_ context.Context, caller api.Caller, raw json.RawMessage) (any, error) {
|
|
28
|
-
if !caller.Admin() {
|
|
29
|
-
return nil, errors.New("admin_required")
|
|
30
|
-
}
|
|
31
|
-
var in struct {
|
|
32
|
-
Action string `json:"action"`
|
|
33
|
-
}
|
|
34
|
-
if err := decodeParams(raw, &in); err != nil {
|
|
35
|
-
return nil, err
|
|
36
|
-
}
|
|
37
|
-
if in.Action != "start" && in.Action != "stop" && in.Action != "purge" {
|
|
38
|
-
return nil, api.BadRequest("executor_control_invalid")
|
|
39
|
-
}
|
|
40
|
-
dir := filepath.Join(cfg.DataDir, "executor")
|
|
41
|
-
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
42
|
-
return nil, err
|
|
43
|
-
}
|
|
44
|
-
info, err := os.Lstat(dir)
|
|
45
|
-
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
46
|
-
return nil, errors.New("executor_control_directory_invalid")
|
|
47
|
-
}
|
|
48
|
-
if err := writeFileAtomic(filepath.Join(dir, "control"), []byte(in.Action)); err != nil {
|
|
49
|
-
return nil, err
|
|
50
|
-
}
|
|
51
|
-
return map[string]bool{"written": true}, nil
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// The executor closes its socket while paused: the package is stopped, or an
|
|
55
|
-
// upgrade changed the pins and awaits administrator approval.
|
|
56
|
-
func (cfg Config) executorStatus(ctx context.Context, _ api.Caller, _ json.RawMessage) (any, error) {
|
|
57
|
-
var status json.RawMessage
|
|
58
|
-
err := cfg.executor().Call(ctx, "status", nil, nil, &status)
|
|
59
|
-
var refusal *api.Error
|
|
60
|
-
if errors.As(err, &refusal) && refusal.Code == delegate.CodeExecutorUnavailable {
|
|
61
|
-
next := "Start the package."
|
|
62
|
-
if cfg.startRequested() {
|
|
63
|
-
next = "The package is running: an administrator approves background access again in Administrator setup; the executor log names the cause."
|
|
64
|
-
}
|
|
65
|
-
return map[string]any{"ready": false, "state": "paused", "reason": "executor_not_listening", "next": next}, nil
|
|
66
|
-
}
|
|
67
|
-
return status, err
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Package start and stop write the lifecycle mailbox; its last request tells a
|
|
71
|
-
// stopped package from a running one whose executor stays paused.
|
|
72
|
-
func (cfg Config) startRequested() bool {
|
|
73
|
-
f, err := os.Open(filepath.Join(cfg.DataDir, "executor", "control"))
|
|
74
|
-
if err != nil {
|
|
75
|
-
return false
|
|
76
|
-
}
|
|
77
|
-
defer f.Close()
|
|
78
|
-
b := make([]byte, 16)
|
|
79
|
-
n, _ := f.Read(b)
|
|
80
|
-
return strings.TrimSpace(string(b[:n])) == "start"
|
|
17
|
+
return append(delegate.ExecutorRoutes(cfg.executor(), cfg.DataDir), api.HandleRequest("root.run", cfg.executorRootRun))
|
|
81
18
|
}
|
|
82
19
|
|
|
83
20
|
func (cfg Config) executorRootRun(ctx context.Context, caller api.Caller, req *http.Request, raw json.RawMessage) (any, error) {
|
|
@@ -20,39 +20,20 @@ func init() {
|
|
|
20
20
|
sampleOutcome["root.run"] = "session_required"
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if
|
|
32
|
-
t.
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
for _, raw := range []string{`{"action":"start;id"}`, `{"action":"start\nstop"}`, `{"action":""}`, `{"action":"stop","path":"/etc/passwd"}`} {
|
|
36
|
-
if code, _ := call(t, cfg, alice, "executor.control", raw); code != "bad_request" {
|
|
37
|
-
t.Fatalf("accepted %s: %s", raw, code)
|
|
23
|
+
// The SDK serves executor.status and executor.control and tests them; the
|
|
24
|
+
// App only registers them.
|
|
25
|
+
func TestTheSDKsExecutorRoutesAreRegistered(t *testing.T) {
|
|
26
|
+
registered := map[string]bool{}
|
|
27
|
+
for _, r := range Routes(testConfig(t)) {
|
|
28
|
+
registered[r.Method()] = true
|
|
29
|
+
}
|
|
30
|
+
for _, method := range []string{"executor.status", "executor.control", "root.run"} {
|
|
31
|
+
if !registered[method] {
|
|
32
|
+
t.Errorf("%s is not registered", method)
|
|
38
33
|
}
|
|
39
34
|
}
|
|
40
35
|
}
|
|
41
36
|
|
|
42
|
-
func TestExecutorControlRejectsSymlinkDirectory(t *testing.T) {
|
|
43
|
-
cfg := testConfig(t)
|
|
44
|
-
outside := t.TempDir()
|
|
45
|
-
if err := os.Symlink(outside, filepath.Join(cfg.DataDir, "executor")); err != nil {
|
|
46
|
-
t.Fatal(err)
|
|
47
|
-
}
|
|
48
|
-
if code, _ := call(t, cfg, alice, "executor.control", `{"action":"stop"}`); code == "" {
|
|
49
|
-
t.Fatal("accepted symlink")
|
|
50
|
-
}
|
|
51
|
-
if _, err := os.Stat(filepath.Join(outside, "control")); !os.IsNotExist(err) {
|
|
52
|
-
t.Fatal("wrote outside data directory")
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
37
|
func TestExecutorRootRunRequiresCurrentDSMCookie(t *testing.T) {
|
|
57
38
|
t.Setenv("HTTP_COOKIE", "")
|
|
58
39
|
if code, _ := call(t, testConfig(t), alice, "root.run", `{"name":"whoami"}`); code == "" {
|
|
@@ -60,59 +41,6 @@ func TestExecutorRootRunRequiresCurrentDSMCookie(t *testing.T) {
|
|
|
60
41
|
}
|
|
61
42
|
}
|
|
62
43
|
|
|
63
|
-
// The executor closes its listener while paused (package stopped, or pins
|
|
64
|
-
// awaiting administrator approval). Status then answers, not "internal".
|
|
65
|
-
func TestExecutorStatusReportsAPausedExecutor(t *testing.T) {
|
|
66
|
-
cfg := testConfig(t)
|
|
67
|
-
useExecutorSocketBase(t)
|
|
68
|
-
check := func(name string) {
|
|
69
|
-
t.Helper()
|
|
70
|
-
code, envelope := call(t, cfg, alice, "executor.status", `{}`)
|
|
71
|
-
out, _ := envelope["data"].(map[string]any)
|
|
72
|
-
if code != "" || out["ready"] != false || out["state"] != "paused" || out["reason"] != "executor_not_listening" || out["next"] == "" || out["next"] == nil {
|
|
73
|
-
t.Fatalf("%s: %s %v", name, code, out)
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
check("no socket")
|
|
77
|
-
if code, _ := call(t, cfg, bob, "executor.status", `{}`); code != "" {
|
|
78
|
-
t.Fatal("authenticated user cannot read a paused status", code)
|
|
79
|
-
}
|
|
80
|
-
dir := filepath.Join(executorSocketBase, cfg.PackageID)
|
|
81
|
-
os.MkdirAll(dir, 0700)
|
|
82
|
-
ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: filepath.Join(dir, "executor.sock"), Net: "unix"})
|
|
83
|
-
if err != nil {
|
|
84
|
-
t.Fatal(err)
|
|
85
|
-
}
|
|
86
|
-
ln.SetUnlinkOnClose(false)
|
|
87
|
-
ln.Close()
|
|
88
|
-
check("closed listener")
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// next follows the last lifecycle request: a running package needs approval
|
|
92
|
-
// again, not a start.
|
|
93
|
-
func TestExecutorStatusNextFollowsTheLifecycleRequest(t *testing.T) {
|
|
94
|
-
cfg := testConfig(t)
|
|
95
|
-
useExecutorSocketBase(t)
|
|
96
|
-
next := func() string {
|
|
97
|
-
t.Helper()
|
|
98
|
-
_, envelope := call(t, cfg, alice, "executor.status", `{}`)
|
|
99
|
-
out, _ := envelope["data"].(map[string]any)
|
|
100
|
-
s, _ := out["next"].(string)
|
|
101
|
-
return s
|
|
102
|
-
}
|
|
103
|
-
if !strings.HasPrefix(next(), "Start the package.") {
|
|
104
|
-
t.Fatal("no request:", next())
|
|
105
|
-
}
|
|
106
|
-
mailbox := filepath.Join(cfg.DataDir, "executor")
|
|
107
|
-
os.MkdirAll(mailbox, 0700)
|
|
108
|
-
for request, start := range map[string]bool{"stop\n": true, "start\n": false, "start": false} {
|
|
109
|
-
os.WriteFile(filepath.Join(mailbox, "control"), []byte(request), 0600)
|
|
110
|
-
if got := next(); strings.HasPrefix(got, "Start the package") != start || got == "" {
|
|
111
|
-
t.Fatalf("%q: %s", request, got)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
44
|
// fakeExecutor answers each v1 request with replies[op] and records the ops.
|
|
117
45
|
type fakeExecutor struct {
|
|
118
46
|
mu sync.Mutex
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// A root-ops App's pages wait for its executor: the SDK's ExecutorApproval
|
|
3
|
+
// takes their place until an administrator approves it with the DSM
|
|
4
|
+
// password, and tells anyone else to ask one. Without ctx.executor (a dev
|
|
5
|
+
// server) there is nothing to approve.
|
|
6
|
+
export const holdsPages = true
|
|
7
|
+
</script>
|
|
8
|
+
<script setup lang="ts">
|
|
9
|
+
import type {Context} from '@synopackageland/sdk'
|
|
10
|
+
import {ExecutorApproval} from '@synopackageland/sdk/vue'
|
|
11
|
+
defineProps<{ctx:Context;admin:boolean}>()
|
|
12
|
+
</script>
|
|
13
|
+
<template>
|
|
14
|
+
<ExecutorApproval v-if="ctx.executor" :admin="admin" :locale="ctx.locale" :ctx="ctx"><slot /></ExecutorApproval>
|
|
15
|
+
<slot v-else />
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import {afterEach,expect,test,vi} from 'vitest'
|
|
3
|
+
import {nextTick} from 'vue'
|
|
4
|
+
import type {Context} from '@synopackageland/sdk'
|
|
5
|
+
import {mount} from '../src/main'
|
|
6
|
+
import {dsmWindow} from './context'
|
|
7
|
+
const cleanup:Array<()=>void>=[]
|
|
8
|
+
afterEach(()=>cleanup.splice(0).forEach(fn=>fn()))
|
|
9
|
+
async function settle(){for(let i=0;i<30;i++){await Promise.resolve();await nextTick()}}
|
|
10
|
+
function open(admin:boolean,executor?:Context['executor']){
|
|
11
|
+
const call=vi.fn(async(method:string)=>{
|
|
12
|
+
if(method==='app.status')return {package:'Root',version:'1',user:admin?'admin':'alice',admin}
|
|
13
|
+
if(method==='greeting.get')return {text:'Hello Root'}
|
|
14
|
+
if(method==='delegate.status')return {state:'ready',disabled:false,lifetime:'until-revoked',destinations:['save']}
|
|
15
|
+
if(method==='delegate.destination')return {key:'save',selection:{scope:'home',root:'/home',generation:0},destination:null,status:'missing'}
|
|
16
|
+
if(method==='delegate.list')return {delegations:[]}
|
|
17
|
+
if(method==='delegate.operations.list')return {operations:[]}
|
|
18
|
+
throw {code:'unknown_method'}
|
|
19
|
+
})
|
|
20
|
+
const root=document.createElement('div');document.body.append(root)
|
|
21
|
+
const app=mount(root,dsmWindow({call,user:{name:admin?'admin':'alice',isAdmin:admin},executor}))
|
|
22
|
+
cleanup.push(()=>{app.unmount();root.remove()})
|
|
23
|
+
return root
|
|
24
|
+
}
|
|
25
|
+
function awaitingApproval(){
|
|
26
|
+
let state:'needs-admin-approval'|'ready'='needs-admin-approval'
|
|
27
|
+
return {status:vi.fn(async()=>({state})),authorize:vi.fn(async()=>{state='ready';return {state}}),control:vi.fn(async()=>({state}))}
|
|
28
|
+
}
|
|
29
|
+
test('the App pages wait until an administrator approves the executor with the DSM password, in the window',async()=>{
|
|
30
|
+
const executor=awaitingApproval()
|
|
31
|
+
const root=open(true,executor as unknown as Context['executor']);await settle()
|
|
32
|
+
expect(root.textContent).not.toContain('Package status')
|
|
33
|
+
expect(root.querySelector('[role=dialog]')).toBeNull()
|
|
34
|
+
const password=root.querySelector<HTMLInputElement>('input[type=password]')!
|
|
35
|
+
expect(password.closest('[data-ui-app]')).not.toBeNull()
|
|
36
|
+
password.value='one-time';password.dispatchEvent(new Event('input',{bubbles:true}));await settle()
|
|
37
|
+
password.closest('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));await settle()
|
|
38
|
+
expect(executor.authorize).toHaveBeenCalledWith('one-time')
|
|
39
|
+
expect(root.textContent).toContain('Package status')
|
|
40
|
+
expect((root.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Hello Root')
|
|
41
|
+
})
|
|
42
|
+
test('anyone else is told to ask an administrator for system access',async()=>{
|
|
43
|
+
const root=open(false,awaitingApproval() as unknown as Context['executor']);await settle()
|
|
44
|
+
expect(root.textContent).toContain('An administrator must approve system access')
|
|
45
|
+
expect(root.querySelector('input[type=password]')).toBeNull()
|
|
46
|
+
expect(root.textContent).not.toContain('Package status')
|
|
47
|
+
})
|
|
48
|
+
test('without an executor (a dev server) the App pages show at once',async()=>{
|
|
49
|
+
const root=open(false);await settle()
|
|
50
|
+
expect(root.textContent).toContain('Package status')
|
|
51
|
+
expect(root.textContent).not.toContain('An administrator must approve system access')
|
|
52
|
+
})
|
|
53
|
+
// ExecutorApproval holds the pages: AppShell's ExecutorNotice would only repeat it.
|
|
54
|
+
test('while the executor awaits approval no notice repeats the approval page',async()=>{
|
|
55
|
+
for(const admin of [true,false]){
|
|
56
|
+
const root=open(admin,awaitingApproval() as unknown as Context['executor']);await settle()
|
|
57
|
+
expect(root.textContent,String(admin)).toContain(admin?'Approve system access':'An administrator must approve system access')
|
|
58
|
+
expect(root.textContent,String(admin)).not.toContain('Some features are unavailable')
|
|
59
|
+
}
|
|
60
|
+
})
|
|
@@ -19,7 +19,11 @@ The base app reads package status and edits the package greeting through
|
|
|
19
19
|
subscriptions; unmount disposes subscriptions and ignores late responses.
|
|
20
20
|
|
|
21
21
|
`--with root-ops` adds the existing DSM approval gate for fixed `root/*.sh`
|
|
22
|
-
operations
|
|
22
|
+
operations, and its `ExecutorGate.vue` holds the pages with the SDK's
|
|
23
|
+
`ExecutorApproval` until the executor is approved: an administrator approves
|
|
24
|
+
with the DSM password, anyone else is told to ask one. A delegate App keeps its
|
|
25
|
+
pages: `AppShell` shows the SDK's `ExecutorNotice` above them while its
|
|
26
|
+
executor waits for approval. `--with jobs` adds package-account jobs and schedules; it combines with root-ops and files. `--with files` enables current-session File Station access,
|
|
23
27
|
and the background picker uses the retained session provider.
|
|
24
28
|
|
|
25
29
|
With `portal:` declared (`synopkgland add portal`, executor Apps only), the status page shows
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"name": "native-dsm-vue-app",
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@lucide/vue": "1.48.0",
|
|
10
|
-
"@synopackageland/sdk": "0.10.
|
|
10
|
+
"@synopackageland/sdk": "0.10.2",
|
|
11
11
|
"@synopackageland/ui": "0.9.4",
|
|
12
12
|
"vue": "3.5.43"
|
|
13
13
|
},
|
|
@@ -1148,9 +1148,9 @@
|
|
|
1148
1148
|
}
|
|
1149
1149
|
},
|
|
1150
1150
|
"node_modules/@synopackageland/sdk": {
|
|
1151
|
-
"version": "0.10.
|
|
1152
|
-
"resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.10.
|
|
1153
|
-
"integrity": "sha512-
|
|
1151
|
+
"version": "0.10.2",
|
|
1152
|
+
"resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.10.2.tgz",
|
|
1153
|
+
"integrity": "sha512-ZX854q5grp/dpCFze/JxmbWS/a6CKOGA8YpgXutMrY807G1JU0PGlfAeeQ4zQ37vZ741VNgOUEo9x5hZDdSB+w==",
|
|
1154
1154
|
"license": "MIT",
|
|
1155
1155
|
"peerDependencies": {
|
|
1156
1156
|
"@lucide/vue": "^1.48.0",
|
|
@@ -5,8 +5,11 @@ import {bridgeErrorCopy, type AppStatus, type Context, type ShellPage} from '@sy
|
|
|
5
5
|
import {AppShell, BackgroundPanel, FilesPage, LoginPage, PortalSettings, createSession, shellPages} from '@synopackageland/sdk/vue'
|
|
6
6
|
import {appCopy} from './i18n'
|
|
7
7
|
import CapabilityPanels from './CapabilityPanels.vue'
|
|
8
|
+
import ExecutorGate, {holdsPages} from './ExecutorGate.vue'
|
|
8
9
|
const props = defineProps<{ctx: Context}>()
|
|
9
10
|
const login = createSession(props.ctx)
|
|
11
|
+
// Pages ExecutorGate holds need no notice above them that repeats its approval.
|
|
12
|
+
const shellCtx = holdsPages ? {...props.ctx, executor: undefined} : props.ctx
|
|
10
13
|
const copy = computed(() => appCopy(props.ctx.locale))
|
|
11
14
|
const app = ref<AppStatus>()
|
|
12
15
|
const busy = ref(false)
|
|
@@ -75,9 +78,10 @@ onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose();text.value=''})
|
|
|
75
78
|
</script>
|
|
76
79
|
|
|
77
80
|
<template>
|
|
78
|
-
<AppShell app-root v-model="page" :ctx="
|
|
81
|
+
<AppShell app-root v-model="page" :ctx="shellCtx" :locale="ctx.locale" :pages="pages" :description="subtitle" :denied="signIn" sidebar-storage-key="__PACKAGE__">
|
|
79
82
|
<template #denied><LoginPage :session="login" :locale="ctx.locale" /></template>
|
|
80
83
|
<template #actions><Button variant="secondary" :disabled="busy" @click="refreshAll">{{ copy.refresh }}</Button><Button v-if="login.enabled.value" variant="secondary" :disabled="busy" @click="signOut">{{ copy.signOut }}</Button></template>
|
|
84
|
+
<ExecutorGate :ctx="ctx" :admin="ctx.user?.isAdmin ?? !!app?.admin">
|
|
81
85
|
<div class="integration-app" :aria-busy="busy" :aria-label="copy.settings">
|
|
82
86
|
<Callout v-if="error" tone="danger" :title="copy.actionRequired"><span role="alert">{{ error }}</span></Callout>
|
|
83
87
|
<p v-if="notice" role="status">{{ notice }}</p>
|
|
@@ -99,5 +103,6 @@ onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose();text.value=''})
|
|
|
99
103
|
<BackgroundPanel v-if="backgroundEnabled && app" ref="background" :key="app.user" :ctx="ctx" />
|
|
100
104
|
</div>
|
|
101
105
|
</div>
|
|
106
|
+
</ExecutorGate>
|
|
102
107
|
</AppShell>
|
|
103
108
|
</template>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The App's pages, shown at once. The root-ops overlay replaces this file to
|
|
3
|
+
// hold them until an administrator approves the executor.
|
|
4
|
+
export const holdsPages = false
|
|
5
|
+
</script>
|
|
6
|
+
<script setup lang="ts">
|
|
7
|
+
import type {Context} from '@synopackageland/sdk'
|
|
8
|
+
defineProps<{ctx:Context;admin:boolean}>()
|
|
9
|
+
</script>
|
|
10
|
+
<template><slot /></template>
|
|
@@ -104,8 +104,8 @@ dependencies = [
|
|
|
104
104
|
|
|
105
105
|
[[package]]
|
|
106
106
|
name = "syno-pkg-land-app"
|
|
107
|
-
version = "0.10.
|
|
108
|
-
source = "git+https://github.com/synopackageland/syno-pkg-land-sdk?tag=v0.10.
|
|
107
|
+
version = "0.10.2"
|
|
108
|
+
source = "git+https://github.com/synopackageland/syno-pkg-land-sdk?tag=v0.10.2#3255e7ef0cfdfc89ae1308bfe1ddf2eabfe6f1b6"
|
|
109
109
|
dependencies = [
|
|
110
110
|
"libc",
|
|
111
111
|
"serde",
|
|
@@ -9,7 +9,7 @@ libc = "0.2"
|
|
|
9
9
|
serde = { version = "1", features = ["derive"] }
|
|
10
10
|
serde_json = "1"
|
|
11
11
|
# The SDK version is the tag; `synopkgland check` reads it from Cargo.lock.
|
|
12
|
-
syno-pkg-land-app = { git = "https://github.com/synopackageland/syno-pkg-land-sdk", tag = "v0.10.
|
|
12
|
+
syno-pkg-land-app = { git = "https://github.com/synopackageland/syno-pkg-land-sdk", tag = "v0.10.2" }
|
|
13
13
|
|
|
14
14
|
[profile.release]
|
|
15
15
|
# A handler panic becomes an "internal" reply; abort would end the App without one.
|