@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
@@ -20,39 +20,20 @@ func init() {
20
20
  sampleOutcome["root.run"] = "session_required"
21
21
  }
22
22
 
23
- func TestExecutorControlAcceptsOnlyFixedActions(t *testing.T) {
24
- cfg := testConfig(t)
25
- for _, action := range []string{"start", "stop", "purge"} {
26
- code, out := call(t, cfg, alice, "executor.control", `{"action":"`+action+`"}`)
27
- if code != "" {
28
- t.Fatalf("%s: %s %v", action, code, out)
29
- }
30
- b, err := os.ReadFile(filepath.Join(cfg.DataDir, "executor/control"))
31
- if err != nil || string(b) != action {
32
- t.Fatalf("control was not written: %s %v", b, err)
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. `--with jobs` adds package-account jobs and schedules; it combines with root-ops and files. `--with files` enables current-session File Station access,
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
@@ -33,14 +37,26 @@ Generated build and unit tests do not establish NAS runtime acceptance.
33
37
  The manifest is dsm-package.yaml schema 2: backend.kind: go and capabilities.files: session, jobs: true, identity: standalone, rootOps: true. Use --with login to declare standalone identity. Existing projects use synopkgland add <cap>...; app entrypoints stay app-owned, SDK conflicts are refused. check, inspect, build, deploy and status accept --json.
34
38
 
35
39
  Use the installed `@synopackageland/ui`; package.json pins its version.
36
- `AppRoot` defaults to app modal scope and stays bounded at `height: 100%`;
37
- `AppLayout wide-layout="window"` keeps the DSM shell in window mode even when
38
- maximized. Only whole-page web/Portal apps should opt into document scope or
39
- `wide-layout="web"`. The DSM host sets the 700px minimum window width.
40
- In a DSM window (`ctx.host.platform().host === 'dsm'`) `AppRoot` takes
40
+ `App.vue` lays the shell out with the SDK's `AppShell` (`@synopackageland/sdk/vue`):
41
+ `pages` from `shellPages`, the header buttons in `#actions` and the pages in its
42
+ default slot. Given `ctx`, it follows the launch. In a DSM desktop window it
43
+ keeps `wide-layout="window"`, even maximized; the DSM host sets the 700px
44
+ minimum window width. A Portal or standalone launch owns the whole page
45
+ (`ctx.fullPage`, from the generated window): `AppShell` takes
46
+ `wide-layout="web"` (the SaaS layout at 1200px or wider, mobile on a phone) and
47
+ a device-width viewport, undone at unmount, since DSM's page has no
48
+ device-width viewport. Its full-page sidebar names the App with
49
+ `ctx.displayName` and `ctx.appIcon` (without them the page title and icon) and
50
+ shows the signed-in DSM account and its role from `ctx.user`. See the ui's
51
+ `docs/adaptive-layout.md`.
52
+ `AppShell`'s `app-root` wraps it in an app-local `AppRoot` (app modal scope, on
53
+ a full page as well, bounded at `height: 100%`) with the UI locale from
54
+ `locale`; do not add another `AppRoot`.
55
+ In a DSM window (not a full page) that `AppRoot` takes
41
56
  `dialog-bounds="viewport"`, so Dialogs can be dragged out of the window, and
42
- its `activate` event calls `ctx.host.activateWindow()` to bring the window to
43
- the front. Elsewhere Dialogs stay inside the App.
57
+ pressing one calls `ctx.host.activateWindow()` to bring the window to the
58
+ front. Elsewhere Dialogs stay inside the App. A confirmed sign-in need sets
59
+ `denied`: the `#denied` slot's `LoginPage` takes the place of the App's pages.
44
60
  Import `themes/dsm7-embedded.css` and assign each mount a unique
45
61
  `app.config.idPrefix` before mounting.
46
62
 
@@ -7,8 +7,8 @@
7
7
  "name": "native-dsm-vue-app",
8
8
  "dependencies": {
9
9
  "@lucide/vue": "1.48.0",
10
- "@synopackageland/sdk": "0.9.9",
11
- "@synopackageland/ui": "0.9.3",
10
+ "@synopackageland/sdk": "0.10.1",
11
+ "@synopackageland/ui": "0.9.4",
12
12
  "vue": "3.5.43"
13
13
  },
14
14
  "devDependencies": {
@@ -1148,13 +1148,13 @@
1148
1148
  }
1149
1149
  },
1150
1150
  "node_modules/@synopackageland/sdk": {
1151
- "version": "0.9.9",
1152
- "resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.9.9.tgz",
1153
- "integrity": "sha512-O73D+s+yJPmYLfZ2l261S1izqa5lAc9hgbuBScE4uoKyydUDfWEhAmb7CUM6Er25wY8nYqG480GMcyfLuESFGw==",
1151
+ "version": "0.10.1",
1152
+ "resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.10.1.tgz",
1153
+ "integrity": "sha512-gs127yFWWYkrNS9lNK/2oHcQ2l0KkhH8dhOAqOScDoGvON212vweVczStdkraesn5Gway+aIMoBqz18uSsD/OA==",
1154
1154
  "license": "MIT",
1155
1155
  "peerDependencies": {
1156
1156
  "@lucide/vue": "^1.48.0",
1157
- "@synopackageland/ui": "^0.9.3",
1157
+ "@synopackageland/ui": "^0.9.4",
1158
1158
  "vue": "^3.5.0"
1159
1159
  },
1160
1160
  "peerDependenciesMeta": {
@@ -1167,9 +1167,9 @@
1167
1167
  }
1168
1168
  },
1169
1169
  "node_modules/@synopackageland/ui": {
1170
- "version": "0.9.3",
1171
- "resolved": "https://registry.npmjs.org/@synopackageland/ui/-/ui-0.9.3.tgz",
1172
- "integrity": "sha512-CcCpSiQ2y4GJnbWjBxRrPO6QzLboMdEzqgbMdpXGaEI3IaTrxwS6zy8yXuuMBQHuqAcfBf3jL7fmcL9A58oVqw==",
1170
+ "version": "0.9.4",
1171
+ "resolved": "https://registry.npmjs.org/@synopackageland/ui/-/ui-0.9.4.tgz",
1172
+ "integrity": "sha512-yu0RrYtT/QcAn/lNwqZBoqqjLTXrpqqnSWB+0swGou8IWLrhfwXrHVBf+oJc+NjLj+G4kB7AyP9kbvw9FHxNOA==",
1173
1173
  "license": "MIT",
1174
1174
  "dependencies": {
1175
1175
  "@internationalized/date": "3.12.4",
@@ -8,8 +8,8 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@lucide/vue": "1.48.0",
11
- "@synopackageland/sdk": "0.9.9",
12
- "@synopackageland/ui": "0.9.3",
11
+ "@synopackageland/sdk": "0.10.1",
12
+ "@synopackageland/ui": "0.9.4",
13
13
  "vue": "3.5.43"
14
14
  },
15
15
  "devDependencies": {
@@ -1,12 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
3
- import {AppRoot, ConfigProvider, Button, Callout, Form, FormActions, FormItem, SettingsSection, Textarea} from '@synopackageland/ui'
4
- import type {AppStatus, Context, ShellPage} from '@synopackageland/sdk'
5
- import {AppShell, BackgroundPanel, FilesPage, LoginPage, PortalSettings, createSession, shellPages, uiLocale} from '@synopackageland/sdk/vue'
3
+ import {Button, Callout, Form, FormActions, FormItem, SettingsSection, Textarea} from '@synopackageland/ui'
4
+ import {bridgeErrorCopy, type AppStatus, type Context, type ShellPage} from '@synopackageland/sdk'
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)
@@ -17,17 +20,16 @@ let disposed = false
17
20
  let unsubscribe: (() => void) | undefined
18
21
  // The CLI substitutes this only for a declared package-account background worker.
19
22
  const backgroundEnabled = (('__BACKGROUND_ENABLED__') as string) === 'true'
20
- // In a DSM window, dialogs may leave the window as DSM's own do; a press on one
21
- // does not reach the window, so it brings the window to the front itself.
22
- const inDsmWindow = props.ctx.host?.platform().host === 'dsm'
23
- function toFront() {props.ctx.host.activateWindow()}
24
23
  const page = ref<ShellPage>('status')
25
24
  const pages = computed(() => shellPages({background:backgroundEnabled}))
25
+ // Only a confirmed sign-in need replaces the App's pages; the sign-in page draws its own title.
26
+ const signIn = computed(() => login.ready.value && login.enabled.value && !login.subject.value && {header: false as const})
26
27
  const subtitle = computed(() => app.value ? `${app.value.package} · ${app.value.user} · ${app.value.version}` : undefined)
27
28
  function message(e: unknown) {
28
29
  const code = (e as {code?: string})?.code || 'request_failed'
29
30
  const errors = copy.value.errors as Record<string, string>
30
- return (code !== 'request_failed' && errors[code]) || errors.request_failed!.replace('{code}', code)
31
+ // A bridge App's runtime adds codes (app_unavailable, a service's refusal) the SDK words.
32
+ return bridgeErrorCopy(e, props.ctx.locale) ?? ((code !== 'request_failed' && errors[code]) || errors.request_failed!.replace('{code}', code))
31
33
  }
32
34
  let again = false
33
35
  async function refresh() {
@@ -76,33 +78,31 @@ onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose();text.value=''})
76
78
  </script>
77
79
 
78
80
  <template>
79
- <AppRoot modal-scope="app" :dialog-bounds="inDsmWindow ? 'viewport' : 'app'" style="height: 100%" @activate="toFront">
80
- <ConfigProvider :locale="uiLocale(ctx.locale)">
81
- <LoginPage v-if="login.ready.value && login.enabled.value && !login.subject.value" :session="login" :locale="ctx.locale" />
82
- <AppShell v-else v-model="page" :locale="ctx.locale" :pages="pages" :description="subtitle">
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
- <div class="integration-app" :aria-busy="busy" :aria-label="copy.settings">
85
- <Callout v-if="error" tone="danger" :title="copy.actionRequired"><span role="alert">{{ error }}</span></Callout>
86
- <p v-if="notice" role="status">{{ notice }}</p>
87
- <div data-page="status" v-show="page==='status'">
88
- <SettingsSection :title="copy.statusTitle" :collapsible="false" :loading="busy && !app">
89
- <p v-if="app">{{ app.package }} · {{ app.user }} · {{ app.version }}</p>
90
- <p v-else-if="error" role="status">{{ copy.statusError }}</p>
91
- </SettingsSection>
92
- <PortalSettings v-if="ctx.portal && app" :key="portalKey" :portal="ctx.portal" />
93
- <SettingsSection v-if="app?.admin" :title="copy.greeting" :collapsible="false">
94
- <Form direction="vertical" @submit.prevent="save"><FormItem :label="copy.greeting"><Textarea v-model="text" :disabled="busy" /></FormItem>
95
- <FormActions><Button variant="primary" :disabled="busy || !text.trim()" @click="save">{{ copy.save }}</Button></FormActions>
96
- </Form>
97
- </SettingsSection>
98
- </div>
99
- <CapabilityPanels v-if="app" ref="panels" :key="app.user" :ctx="ctx" :admin="app.admin" />
100
- <FilesPage v-if="page==='status'" :ctx="ctx" />
101
- <div data-page="background" v-show="page==='background'">
102
- <BackgroundPanel v-if="backgroundEnabled && app" ref="background" :key="app.user" :ctx="ctx" />
103
- </div>
81
+ <AppShell app-root v-model="page" :ctx="shellCtx" :locale="ctx.locale" :pages="pages" :description="subtitle" :denied="signIn" sidebar-storage-key="__PACKAGE__">
82
+ <template #denied><LoginPage :session="login" :locale="ctx.locale" /></template>
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">
85
+ <div class="integration-app" :aria-busy="busy" :aria-label="copy.settings">
86
+ <Callout v-if="error" tone="danger" :title="copy.actionRequired"><span role="alert">{{ error }}</span></Callout>
87
+ <p v-if="notice" role="status">{{ notice }}</p>
88
+ <div data-page="status" v-show="page==='status'">
89
+ <SettingsSection :title="copy.statusTitle" :collapsible="false" :loading="busy && !app">
90
+ <p v-if="app">{{ app.package }} · {{ app.user }} · {{ app.version }}</p>
91
+ <p v-else-if="error" role="status">{{ copy.statusError }}</p>
92
+ </SettingsSection>
93
+ <PortalSettings v-if="ctx.portal && app" :key="portalKey" :portal="ctx.portal" />
94
+ <SettingsSection v-if="app?.admin" :title="copy.greeting" :collapsible="false">
95
+ <Form direction="vertical" @submit.prevent="save"><FormItem :label="copy.greeting"><Textarea v-model="text" :disabled="busy" /></FormItem>
96
+ <FormActions><Button variant="primary" :disabled="busy || !text.trim()" @click="save">{{ copy.save }}</Button></FormActions>
97
+ </Form>
98
+ </SettingsSection>
104
99
  </div>
105
- </AppShell>
106
- </ConfigProvider>
107
- </AppRoot>
100
+ <CapabilityPanels v-if="app" ref="panels" :key="app.user" :ctx="ctx" :admin="app.admin" />
101
+ <FilesPage v-if="page==='status'" :ctx="ctx" />
102
+ <div data-page="background" v-show="page==='background'">
103
+ <BackgroundPanel v-if="backgroundEnabled && app" ref="background" :key="app.user" :ctx="ctx" />
104
+ </div>
105
+ </div>
106
+ </ExecutorGate>
107
+ </AppShell>
108
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>
@@ -2,7 +2,8 @@
2
2
  import {expect,test,vi} from 'vitest'
3
3
  import {nextTick} from 'vue'
4
4
  import {mount} from '../src/main'
5
- import type {Context} from '@synopackageland/sdk'
5
+ import {createStandaloneContext, type Context} from '@synopackageland/sdk'
6
+ import {dsmWindow} from './context'
6
7
  async function settle(){for(let i=0;i<16;i++){await Promise.resolve();await nextTick()}}
7
8
  function fixture(id:string){
8
9
  const reopen:Array<()=>void>=[]
@@ -12,7 +13,7 @@ function fixture(id:string){
12
13
  if(method==='greeting.set')return {}
13
14
  throw {code:'unknown_method'}
14
15
  })
15
- const ctx={locale:'en',call,reopen(fn:()=>void){reopen.push(fn);return ()=>{}}} as Context
16
+ const ctx=dsmWindow({call,reopen(fn:()=>void){reopen.push(fn);return ()=>{}}})
16
17
  const root=document.createElement('div');document.body.append(root)
17
18
  const app=mount(root,ctx)
18
19
  return {root,app,call,reopen,ctx,close(){app.unmount();root.remove()}}
@@ -78,7 +79,7 @@ test('the app renders and loads status while the session check is still pending'
78
79
  if(method==='greeting.get')return {text:'Hello Eager'}
79
80
  throw {code:'unknown_method'}
80
81
  })
81
- const ctx={locale:'en',call,reopen(){return ()=>{}}} as unknown as Context
82
+ const ctx=dsmWindow({call})
82
83
  const root=document.createElement('div');document.body.append(root)
83
84
  const app=mount(root,ctx)
84
85
  try{
@@ -99,7 +100,7 @@ test('a status request that raced the session check is retried once the session
99
100
  if(method==='greeting.get')return {text:'Hello Raced'}
100
101
  throw {code:'unknown_method'}
101
102
  })
102
- const ctx={locale:'en',call,reopen(){return ()=>{}}} as unknown as Context
103
+ const ctx=dsmWindow({call})
103
104
  const root=document.createElement('div');document.body.append(root)
104
105
  const app=mount(root,ctx)
105
106
  try{
@@ -108,13 +109,14 @@ test('a status request that raced the session check is retried once the session
108
109
  expect(root.textContent).toContain('Raced · admin · 1')
109
110
  }finally{app.unmount();root.remove()}
110
111
  })
111
- function hosted(host?:'dsm'|'standalone-web'){
112
+ function hosted(host:'dsm'|'standalone-web'){
112
113
  const call=vi.fn(async(method:string)=>{
113
114
  if(method==='app.status')return {package:'Hosted',version:'1',user:'admin',admin:false}
114
115
  throw {code:'unknown_method'}
115
116
  })
116
- const activateWindow=vi.fn(()=>({status:'requested'}))
117
- const ctx={locale:'en',call,reopen(){return ()=>{}},...(host?{host:{platform:()=>({host}),activateWindow}}:{})} as unknown as Context
117
+ // Outside DSM the App owns the page.
118
+ const ctx=host==='dsm'?dsmWindow({call}):createStandaloneContext({call})
119
+ const activateWindow=ctx.host.activateWindow
118
120
  const root=document.createElement('div');document.body.append(root)
119
121
  const app=mount(root,ctx)
120
122
  return {activateWindow,close(){app.unmount();root.remove()}}
@@ -140,10 +142,8 @@ test('on a narrow viewport the floating dialogs become sheets that stay on scree
140
142
  }finally{f.close();Object.defineProperty(window,'innerWidth',{configurable:true,value:width})}
141
143
  })
142
144
  test('outside a DSM window dialogs stay inside the app',async()=>{
143
- for(const host of [undefined,'standalone-web'] as const){
144
- const f=hosted(host)
145
- try{await settle();expect(document.querySelector('[data-ui-floating]')).toBeNull()}finally{f.close()}
146
- }
145
+ const f=hosted('standalone-web')
146
+ try{await settle();expect(document.querySelector('[data-ui-floating]')).toBeNull()}finally{f.close()}
147
147
  })
148
148
  test('the status page follows the UI layout rules',async()=>{
149
149
  let answer:(value:unknown)=>void=()=>{}
@@ -153,7 +153,7 @@ test('the status page follows the UI layout rules',async()=>{
153
153
  throw {code:'unknown_method'}
154
154
  })
155
155
  const root=document.createElement('div');document.body.append(root)
156
- const app=mount(root,{locale:'en',call,reopen:()=>()=>{}} as unknown as Context)
156
+ const app=mount(root,dsmWindow({call}))
157
157
  try{
158
158
  await settle()
159
159
  // A loading section keeps its title and shows a skeleton, not a sentence.
@@ -166,3 +166,56 @@ test('the status page follows the UI layout rules',async()=>{
166
166
  expect(root.querySelector('textarea')!.closest('[data-form-item]')!.getAttribute('data-direction')).toBe('vertical')
167
167
  }finally{app.unmount();root.remove()}
168
168
  })
169
+ test('a code the bridge runtime adds shows the SDK text in the DSM language',async()=>{
170
+ const f=fixture('Bridge')
171
+ try{
172
+ await settle();f.call.mockRejectedValue({code:'app_unavailable'});f.reopen.forEach(fn=>fn());await settle()
173
+ expect(f.root.textContent).toContain('The App is not available right now. Try again later.')
174
+ }finally{f.close()}
175
+ })
176
+ test('a Portal or standalone launch lays the App out full-page; a DSM window keeps the window layout',async()=>{
177
+ // jsdom lays nothing out: give every element a wide desktop width.
178
+ const box=vi.spyOn(HTMLElement.prototype,'getBoundingClientRect').mockReturnValue({width:1400,height:800} as DOMRect)
179
+ const viewport=()=>document.head.querySelector('meta[name="viewport"]')
180
+ const window=fixture('Window')
181
+ const call=vi.fn(async(method:string)=>method==='app.status'?{package:'Page',version:'1',user:'alice',admin:false}:{})
182
+ const ctx=createStandaloneContext({call,displayName:'Page App',appIcon:'webman/3rdparty/Page/images/icon_64.png',user:{name:'alice',isAdmin:false}})
183
+ const root=document.createElement('div');document.body.append(root)
184
+ try{
185
+ await settle()
186
+ expect(window.root.querySelector('[data-ui-layout-mode]')?.getAttribute('data-ui-layout-mode')).toBe('window')
187
+ expect(viewport()).toBeNull()
188
+ // A DSM window has DSM's own title bar and account: the sidebar shows no user.
189
+ expect(window.root.querySelector('[data-ui-sidebar-footer]')).toBeNull()
190
+ const app=mount(root,ctx);await settle()
191
+ expect(root.querySelector('[data-ui-layout-mode]')?.getAttribute('data-ui-layout-mode')).toBe('web')
192
+ // DSM's page has no device-width viewport, so a phone would never reach the mobile layout.
193
+ expect(viewport()?.getAttribute('content')).toContain('width=device-width')
194
+ const brand=root.querySelector('[data-ui-sidebar-brand]')
195
+ expect(brand?.textContent).toContain('Page App')
196
+ expect(brand?.querySelector('img')?.getAttribute('src')).toBe('webman/3rdparty/Page/images/icon_64.png')
197
+ // The signed-in DSM account and its role, from ctx.user.
198
+ expect(root.querySelector('[data-ui-sidebar-footer]')?.textContent).toContain('alice')
199
+ expect(root.querySelector('[data-ui-sidebar-footer]')?.textContent).toContain('DSM user')
200
+ app.unmount()
201
+ expect(viewport()).toBeNull()
202
+ }finally{window.close();root.remove();box.mockRestore()}
203
+ })
204
+ test('a needed sign-in takes the shell page in place of the App pages, inside the App root',async()=>{
205
+ const call=vi.fn(async(method:string)=>{
206
+ if(method==='identity.status')return {enabled:true}
207
+ if(method==='identity.session')return {user:'alice',uid:'1',expiresAt:new Date(Date.now()-1000).toISOString()}
208
+ throw {code:'session_required'}
209
+ })
210
+ const root=document.createElement('div');document.body.append(root)
211
+ const app=mount(root,dsmWindow({call}))
212
+ try{
213
+ await settle()
214
+ const form=root.querySelector('form')
215
+ expect(form).not.toBeNull()
216
+ // The shell lays the sign-in page out as one of its pages: no sidebar, the page body's padding.
217
+ expect(form!.closest('[data-page-content-body]')).not.toBeNull()
218
+ expect(root.querySelector('nav')).toBeNull()
219
+ expect(form!.closest('[data-ui-app]')).not.toBeNull()
220
+ }finally{app.unmount();root.remove()}
221
+ })
@@ -0,0 +1,8 @@
1
+ import {vi} from 'vitest'
2
+ import {createStandaloneContext, createStandaloneHost, type Context} from '@synopackageland/sdk'
3
+ /** The ctx the CLI gives a DSM desktop window; activateWindow is a spy. */
4
+ export function dsmWindow(fields: Pick<Context, 'call'> & Partial<Omit<Context, 'call'>>): Context {
5
+ const host = createStandaloneHost({host: 'dsm', dsmStandalone: false})
6
+ vi.spyOn(host, 'activateWindow')
7
+ return createStandaloneContext({fullPage: false, host, ...fields})
8
+ }
@@ -18,10 +18,9 @@ test('FormItem labels sit above their controls and the UI binds the control id',
18
18
  expect(items.filter(file=>/<Form(?![^>]*direction="vertical")[\s>]|field\.controlId|#default="\{field\}"/.test(template(file)))).toEqual([])
19
19
  })
20
20
  // UI built-in text follows the Provider locale (nine locales), so every app root sets it once and no
21
- // component repeats a per-control locale or download label.
21
+ // component repeats a per-control locale or download label. AppShell's app-root sets it from its locale.
22
22
  test('app roots set the UI locale once and components do not translate UI text themselves',()=>{
23
- const roots=sources(root).filter(file=>uses(file,'AppRoot'))
24
- expect(roots.length).toBeGreaterThan(0)
25
- expect(roots.filter(file=>!uses(file,'ConfigProvider'))).toEqual([])
23
+ expect(sources(root).filter(file=>/<AppShell[^>]*\sapp-root[\s>]/.test(template(file))).length).toBeGreaterThan(0)
24
+ expect(sources(root).filter(file=>uses(file,'AppRoot')&&!uses(file,'ConfigProvider'))).toEqual([])
26
25
  expect(sources(root).filter(file=>/download-label|external-label|<FilePicker[^>]*:locale=/.test(template(file)))).toEqual([])
27
26
  })
@@ -3,13 +3,13 @@
3
3
  import {expect,test,vi} from 'vitest'
4
4
  import {nextTick} from 'vue'
5
5
  import {mount} from '../src/main'
6
- import type {Context} from '@synopackageland/sdk'
6
+ import {dsmWindow} from './context'
7
7
  import {appCopy} from '../src/i18n'
8
8
  import {identityCopy} from '@synopackageland/sdk/vue'
9
9
  async function settle(){for(let i=0;i<20;i++){await Promise.resolve();await nextTick()}}
10
10
  const buttons=(root:HTMLElement)=>[...root.querySelectorAll('button')].map(b=>b.textContent?.trim())
11
11
  function start(locale:string,call:(method:string)=>Promise<unknown>){
12
- const ctx={locale,call:vi.fn(call),reopen(){return ()=>{}}} as unknown as Context
12
+ const ctx=dsmWindow({locale,call:vi.fn(call) as never})
13
13
  const root=document.createElement('div');document.body.append(root)
14
14
  const app=mount(root,ctx)
15
15
  return {root,close(){app.unmount();root.remove()}}