@synopackageland/templates 0.9.8 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +27 -27
  2. package/package.json +2 -2
  3. package/templates/_shared/AGENTS.md +53 -15
  4. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
  5. package/templates/native-go/backend/cmd/app/main.go +15 -1
  6. package/templates/native-go/backend/cmd/app/main_test.go +33 -0
  7. package/templates/native-go/backend/go.mod +1 -1
  8. package/templates/native-go/backend/go.sum +2 -2
  9. package/templates/native-go/i18n/glossary.json +3 -1
  10. package/templates/native-go/i18n/messages/de.json +6 -0
  11. package/templates/native-go/i18n/messages/en.json +6 -0
  12. package/templates/native-go/i18n/messages/es.json +6 -0
  13. package/templates/native-go/i18n/messages/fr.json +6 -0
  14. package/templates/native-go/i18n/messages/it.json +6 -0
  15. package/templates/native-go/i18n/messages/ja.json +6 -0
  16. package/templates/native-go/i18n/messages/ko.json +6 -0
  17. package/templates/native-go/i18n/messages/zh-CN.json +6 -0
  18. package/templates/native-go/i18n/messages/zh-TW.json +6 -0
  19. package/templates/native-go/ui/app.js +2 -1
  20. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +3 -2
  21. package/templates/native-go-jobs/frontend/test/background-page.test.ts +4 -4
  22. package/templates/native-go-vue/README.md +19 -7
  23. package/templates/native-go-vue/frontend/package-lock.json +9 -9
  24. package/templates/native-go-vue/frontend/package.json +2 -2
  25. package/templates/native-go-vue/frontend/src/App.vue +31 -36
  26. package/templates/native-go-vue/frontend/test/apps.test.ts +65 -12
  27. package/templates/native-go-vue/frontend/test/context.ts +8 -0
  28. package/templates/native-go-vue/frontend/test/native-controls.test.ts +3 -4
  29. package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +2 -2
  30. package/templates/native-node/README.md +153 -0
  31. package/templates/native-node/backend/app/package.json +4 -0
  32. package/templates/native-node/backend/app/permissions.json +5 -0
  33. package/templates/native-node/backend/package-lock.json +59 -0
  34. package/templates/native-node/backend/package.json +17 -0
  35. package/templates/native-node/backend/src/delegate.ts +5 -0
  36. package/templates/native-node/backend/src/jobs.ts +5 -0
  37. package/templates/native-node/backend/src/main.ts +4 -0
  38. package/templates/native-node/backend/src/params.ts +11 -0
  39. package/templates/native-node/backend/src/root-ops.ts +5 -0
  40. package/templates/native-node/backend/src/routes.ts +97 -0
  41. package/templates/native-node/backend/src/service.ts +22 -0
  42. package/templates/native-node/backend/test/routes.test.ts +75 -0
  43. package/templates/native-node/backend/test/support.ts +67 -0
  44. package/templates/native-node/backend/tsconfig.build.json +8 -0
  45. package/templates/native-node/backend/tsconfig.json +18 -0
  46. package/templates/native-node/dsm-package.yaml +80 -0
  47. package/templates/native-node/gitignore +4 -0
  48. package/templates/native-node-delegate/backend/src/delegate.ts +35 -0
  49. package/templates/native-node-delegate/backend/test/delegate.test.ts +51 -0
  50. package/templates/native-node-jobs/backend/src/jobs.ts +38 -0
  51. package/templates/native-node-jobs/backend/test/jobs.test.ts +73 -0
  52. package/templates/native-node-root-ops/backend/src/root-ops.ts +16 -0
  53. package/templates/native-node-root-ops/backend/test/root-ops.test.ts +23 -0
  54. package/templates/native-node-vue/README.md +56 -0
  55. package/templates/native-node-vue/gitignore +4 -0
  56. package/templates/native-python/README.md +150 -0
  57. package/templates/native-python/backend/app/delegate.py +5 -0
  58. package/templates/native-python/backend/app/jobs.py +5 -0
  59. package/templates/native-python/backend/app/main.py +5 -0
  60. package/templates/native-python/backend/app/params.py +10 -0
  61. package/templates/native-python/backend/app/permissions.json +5 -0
  62. package/templates/native-python/backend/app/root_ops.py +5 -0
  63. package/templates/native-python/backend/app/routes.py +107 -0
  64. package/templates/native-python/backend/tests/support.py +72 -0
  65. package/templates/native-python/backend/tests/test_routes.py +73 -0
  66. package/templates/native-python/dsm-package.yaml +77 -0
  67. package/templates/native-python/gitignore +2 -0
  68. package/templates/native-python-delegate/backend/app/delegate.py +42 -0
  69. package/templates/native-python-delegate/backend/tests/test_delegate.py +58 -0
  70. package/templates/native-python-jobs/backend/app/jobs.py +40 -0
  71. package/templates/native-python-jobs/backend/tests/test_jobs.py +72 -0
  72. package/templates/native-python-root-ops/backend/app/root_ops.py +18 -0
  73. package/templates/native-python-root-ops/backend/tests/test_root_ops.py +34 -0
  74. package/templates/native-python-vue/README.md +56 -0
  75. package/templates/native-python-vue/gitignore +3 -0
  76. package/templates/native-rust/README.md +74 -296
  77. package/templates/native-rust/backend/Cargo.lock +6 -6
  78. package/templates/native-rust/backend/Cargo.toml +2 -10
  79. package/templates/native-rust/backend/src/delegate.rs +6 -0
  80. package/templates/native-rust/backend/src/jobs.rs +6 -0
  81. package/templates/native-rust/backend/src/main.rs +12 -0
  82. package/templates/native-rust/backend/src/root_ops.rs +6 -0
  83. package/templates/native-rust/backend/src/routes.rs +352 -0
  84. package/templates/native-rust/backend/src/test_support.rs +91 -0
  85. package/templates/native-rust/dsm-package.yaml +10 -21
  86. package/templates/native-rust-delegate/backend/src/delegate.rs +171 -0
  87. package/templates/native-rust-jobs/backend/src/jobs.rs +240 -0
  88. package/templates/native-rust-root-ops/backend/src/root_ops.rs +83 -0
  89. package/templates/native-rust-vue/README.md +35 -34
  90. package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +0 -207
  91. package/templates/native-rust/backend/src/app/background/mod.rs +0 -424
  92. package/templates/native-rust/backend/src/app/background/owner.rs +0 -77
  93. package/templates/native-rust/backend/src/app/background/owner_tests.rs +0 -174
  94. package/templates/native-rust/backend/src/app/background/schedule.rs +0 -126
  95. package/templates/native-rust/backend/src/app/background/schedule_tests.rs +0 -108
  96. package/templates/native-rust/backend/src/app/background/tests.rs +0 -505
  97. package/templates/native-rust/backend/src/app/background/work.rs +0 -78
  98. package/templates/native-rust/backend/src/app/background/work_tests.rs +0 -123
  99. package/templates/native-rust/backend/src/app/background_routes.rs +0 -22
  100. package/templates/native-rust/backend/src/app/delegate_config.rs +0 -188
  101. package/templates/native-rust/backend/src/app/delegate_routes.rs +0 -22
  102. package/templates/native-rust/backend/src/app/identity.rs +0 -56
  103. package/templates/native-rust/backend/src/app/migrations.rs +0 -79
  104. package/templates/native-rust/backend/src/app/mod.rs +0 -30
  105. package/templates/native-rust/backend/src/app/portal.rs +0 -317
  106. package/templates/native-rust/backend/src/app/root_routes.rs +0 -23
  107. package/templates/native-rust/backend/src/app/routes.rs +0 -315
  108. package/templates/native-rust/backend/src/app/session_files.rs +0 -19
  109. package/templates/native-rust/backend/src/app/shared_folder.rs +0 -383
  110. package/templates/native-rust/backend/src/app/test_support.rs +0 -202
  111. package/templates/native-rust/backend/src/app/uninstall.rs +0 -38
  112. package/templates/native-rust/backend/src/bin/api.rs +0 -72
  113. package/templates/native-rust/backend/src/bin/background-worker.rs +0 -37
  114. package/templates/native-rust/backend/src/build_env.rs +0 -155
  115. package/templates/native-rust/backend/src/lib.rs +0 -6
  116. package/templates/native-rust/backend/src/permissions.rs +0 -54
  117. package/templates/native-rust/backend/src/process.rs +0 -91
  118. package/templates/native-rust/i18n/glossary.json +0 -663
  119. package/templates/native-rust/i18n/messages/de.json +0 -30
  120. package/templates/native-rust/i18n/messages/en.json +0 -30
  121. package/templates/native-rust/i18n/messages/es.json +0 -30
  122. package/templates/native-rust/i18n/messages/fr.json +0 -30
  123. package/templates/native-rust/i18n/messages/it.json +0 -30
  124. package/templates/native-rust/i18n/messages/ja.json +0 -30
  125. package/templates/native-rust/i18n/messages/ko.json +0 -30
  126. package/templates/native-rust/i18n/messages/zh-CN.json +0 -30
  127. package/templates/native-rust/i18n/messages/zh-TW.json +0 -30
  128. package/templates/native-rust/ui/app.css +0 -45
  129. package/templates/native-rust/ui/app.js +0 -111
  130. package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +0 -92
  131. package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +0 -52
  132. package/templates/native-rust-jobs/backend/src/app/background_routes.rs +0 -109
  133. package/templates/native-rust-root-ops/backend/src/app/root_routes.rs +0 -475
@@ -1,8 +1,8 @@
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
8
  const props = defineProps<{ctx: Context}>()
@@ -17,17 +17,16 @@ let disposed = false
17
17
  let unsubscribe: (() => void) | undefined
18
18
  // The CLI substitutes this only for a declared package-account background worker.
19
19
  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
20
  const page = ref<ShellPage>('status')
25
21
  const pages = computed(() => shellPages({background:backgroundEnabled}))
22
+ // Only a confirmed sign-in need replaces the App's pages; the sign-in page draws its own title.
23
+ const signIn = computed(() => login.ready.value && login.enabled.value && !login.subject.value && {header: false as const})
26
24
  const subtitle = computed(() => app.value ? `${app.value.package} · ${app.value.user} · ${app.value.version}` : undefined)
27
25
  function message(e: unknown) {
28
26
  const code = (e as {code?: string})?.code || 'request_failed'
29
27
  const errors = copy.value.errors as Record<string, string>
30
- return (code !== 'request_failed' && errors[code]) || errors.request_failed!.replace('{code}', code)
28
+ // A bridge App's runtime adds codes (app_unavailable, a service's refusal) the SDK words.
29
+ return bridgeErrorCopy(e, props.ctx.locale) ?? ((code !== 'request_failed' && errors[code]) || errors.request_failed!.replace('{code}', code))
31
30
  }
32
31
  let again = false
33
32
  async function refresh() {
@@ -76,33 +75,29 @@ onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose();text.value=''})
76
75
  </script>
77
76
 
78
77
  <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>
78
+ <AppShell app-root v-model="page" :ctx="ctx" :locale="ctx.locale" :pages="pages" :description="subtitle" :denied="signIn" sidebar-storage-key="__PACKAGE__">
79
+ <template #denied><LoginPage :session="login" :locale="ctx.locale" /></template>
80
+ <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>
81
+ <div class="integration-app" :aria-busy="busy" :aria-label="copy.settings">
82
+ <Callout v-if="error" tone="danger" :title="copy.actionRequired"><span role="alert">{{ error }}</span></Callout>
83
+ <p v-if="notice" role="status">{{ notice }}</p>
84
+ <div data-page="status" v-show="page==='status'">
85
+ <SettingsSection :title="copy.statusTitle" :collapsible="false" :loading="busy && !app">
86
+ <p v-if="app">{{ app.package }} · {{ app.user }} · {{ app.version }}</p>
87
+ <p v-else-if="error" role="status">{{ copy.statusError }}</p>
88
+ </SettingsSection>
89
+ <PortalSettings v-if="ctx.portal && app" :key="portalKey" :portal="ctx.portal" />
90
+ <SettingsSection v-if="app?.admin" :title="copy.greeting" :collapsible="false">
91
+ <Form direction="vertical" @submit.prevent="save"><FormItem :label="copy.greeting"><Textarea v-model="text" :disabled="busy" /></FormItem>
92
+ <FormActions><Button variant="primary" :disabled="busy || !text.trim()" @click="save">{{ copy.save }}</Button></FormActions>
93
+ </Form>
94
+ </SettingsSection>
104
95
  </div>
105
- </AppShell>
106
- </ConfigProvider>
107
- </AppRoot>
96
+ <CapabilityPanels v-if="app" ref="panels" :key="app.user" :ctx="ctx" :admin="app.admin" />
97
+ <FilesPage v-if="page==='status'" :ctx="ctx" />
98
+ <div data-page="background" v-show="page==='background'">
99
+ <BackgroundPanel v-if="backgroundEnabled && app" ref="background" :key="app.user" :ctx="ctx" />
100
+ </div>
101
+ </div>
102
+ </AppShell>
108
103
  </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()}}
@@ -0,0 +1,153 @@
1
+ # __DISPLAY_NAME__
2
+
3
+ A native DSM package (`__PACKAGE__`): a Node.js App behind the SDK's runtime,
4
+ and a UI that opens in its own DSM window. It installs as an **unsigned** SPK,
5
+ runs as its own DSM package account, needs no containers, and has no root by default.
6
+
7
+ ```text
8
+ DSM main menu → DSM window (ui/app.js)
9
+ │ ctx.call("greeting.get") same-origin POST + DSM session
10
+ ▼
11
+ /webman/3rdparty/__PACKAGE__/cgi/api.cgi (the SDK runtime, runs as package user)
12
+ 1. transport guards: POST, JSON, same origin, ≤ 1 MiB, bounded JSON complexity
13
+ 2. one DSM authentication, the entry gate and app/permissions.json
14
+ 3. DSM's Node.js v20 package runs `backend/app/main.js` once per call
15
+ 4. your method, which may ask the runtime for services (files, jobs, executor)
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ Needs Node.js 20 or later with `synopkgland` installed. The SPK depends on DSM's **Node.js v20** package (INFO `install_dep_packages`).
21
+
22
+ ```bash
23
+ (cd backend && npm ci && npm test && npm run build) # method tests, then app/*.js
24
+ synopkgland build # → dist/__PACKAGE__-0.1.0-0001-{x86_64,aarch64}.spk
25
+ synopkgland inspect dist/__PACKAGE__-0.1.0-0001-x86_64.spk
26
+ ```
27
+
28
+ Run `npm run build` in `backend` before every `synopkgland check` or `build`: the SPK ships the compiled `backend/app`.
29
+
30
+ Install: Package Center → Settings → General → Trust Level "Any publisher",
31
+ then Manual Install with the SPK for your NAS's CPU. Open
32
+ **__DISPLAY_NAME__** from the DSM main menu: it shows who you are signed in
33
+ as, what the backend reports, and (for administrators) a form that stores a
34
+ greeting in the package's data folder.
35
+
36
+ ## Project layout
37
+
38
+ | Path | What it is | Edit it? |
39
+ | --- | --- | --- |
40
+ | `dsm-package.yaml` | Package identity, version/build, arches, where backend and UI come from | Yes |
41
+ | `ui/app.js`, `ui/app.css` | The app inside the DSM window | Yes — your UI |
42
+ | `i18n/messages/<locale>.json` | UI text in nine languages | Yes |
43
+ | `i18n/glossary.json` | DSM's own UI terms, and which messages must use them | Add terms |
44
+ | `backend/src/routes.ts` | Your methods: `build` registers them | Yes — your backend |
45
+ | `backend/src/jobs.ts`, `root-ops.ts`, `delegate.ts` | Capability examples (stubs unless `init --with` added them) | Yes |
46
+ | `backend/src/main.ts` | Runs the App on the runtime's channel | Rarely |
47
+ | `backend/app/` | `npm run build` output (`*.js`, ignored by git), `package.json` and `permissions.json`: the directory the SPK ships | Only `permissions.json` |
48
+ | `backend/test/` | node:test tests; `call` plays the runtime | Yes |
49
+ | `backend/package.json`, `package-lock.json` | TypeScript (NodeNext) and the Node SDK `@synopackageland/app` from `backend/vendor`, which `synopkgland init` copies from the SDK release | Dependencies |
50
+ | `dist/` | Built SPKs (ignored by git) | No |
51
+
52
+ `build` generates everything else: `INFO`, `conf/privilege`
53
+ (`run-as: package`), lifecycle scripts, icons, the DSM window wrapper and the
54
+ runtime CGI at `ui/cgi/api.cgi`.
55
+
56
+ ## Add a method
57
+
58
+ Register it in `build`. Every method defaults to DSM administrator membership;
59
+ all access rules live in `backend/app/permissions.json`:
60
+
61
+ ```ts
62
+ export function build(cfg: Config): App {
63
+ const app = new App()
64
+ ...
65
+ app.method('notes.list', (ctx, params) => listNotes(cfg, ctx, params)) // new: administrators by default
66
+ return app
67
+ }
68
+
69
+ function listNotes(cfg: Config, ctx: Context, params: unknown) {
70
+ const { folder } = strings(params, 'folder') // anything else is bad_request
71
+ return { owner: ctx.user, folder, notes: [] }
72
+ }
73
+ ```
74
+
75
+ Add a test to `backend/test/` with `call(app, 'notes.list', { folder: 'work' }, { services })`. Call it from the UI with `ctx.call("notes.list", { folder: "work" })`.
76
+
77
+ Throw `new AppError(code, message)` (or `AppError.badRequest(message)`) for an error the browser may see; anything else is `internal`, with the stack in the package's log. Call runtime services through `service()` in `src/service.ts`: it passes the runtime's refusals on to the browser.
78
+
79
+ The runtime refuses before your App runs: `session_required`, `admin_required`,
80
+ `unknown_method`, `request_timeout` and the transport guards' codes. The UI
81
+ shows `error.<code>` from its catalogs.
82
+
83
+ ### Who can call what
84
+
85
+ The shipped policy is:
86
+
87
+ ```json
88
+ {"schema": 1, "default": "administrators", "methods": {}}
89
+ ```
90
+
91
+ The default must remain `administrators`. An intentional exception belongs in
92
+ `methods`, for example `"notes.list": "authenticated"`; `"public"` explicitly
93
+ allows anonymous calls. Such exceptions also enable ordinary-user launcher
94
+ access.
95
+
96
+ | JSON rule | No session | Ordinary user | Administrators group member |
97
+ | --- | --- | --- | --- |
98
+ | absent or `administrators` | `session_required` | `admin_required` | runs |
99
+ | explicit `authenticated` | `session_required` | runs | runs |
100
+ | explicit `public` | runs | runs | runs |
101
+ | method the App does not register | `session_required` | `unknown_method` | `unknown_method` |
102
+
103
+ Missing or invalid JSON, duplicate keys, unknown permissions and rules for
104
+ methods the App does not register fail closed. Changing policy requires
105
+ rebuilding the SPK.
106
+
107
+ ## Capabilities
108
+
109
+ `synopkgland init --with` adds examples that use the runtime's services through
110
+ `ctx.runtime`; each comes with its tests:
111
+
112
+ | `--with` | Methods | Runtime services |
113
+ | --- | --- | --- |
114
+ | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | `files.read` with the caller's session, `bg.submit`, `bg.get`, `bg.cancel` |
115
+ | `root-ops` | `whoami.run` | `root.run` of `root/whoami.sh`, once an administrator approves the executor |
116
+ | `delegate` | `report.save` | `delegate.move_from_staging` into a folder the caller chose |
117
+
118
+ A service refuses with its own code (`limit_exceeded`, `read_failed`, the
119
+ executor's refusals); the examples pass it on to the browser. `synopkgland add`
120
+ works on Go backends only: start a Node.js App with the capabilities it needs.
121
+
122
+ ## The SDK
123
+
124
+ `backend.sdk` in dsm-package.yaml names the SDK release: the build ships its runtime and its Node SDK as `backend/app/node_modules/@synopackageland/app`, and reads the method list from `main.js --describe`. `backend/vendor/@synopackageland/app` is the same SDK for tests and type checks; replace it with the new release's when you change `backend.sdk`. Upgrade the SDK by changing that version; `synopkgland check`
125
+ refuses versions this CLI does not support. The bridge protocol, the services
126
+ and their codes are documented in the SDK repository:
127
+ <https://github.com/synopackageland/syno-pkg-land-sdk> (`spec/bridge/v1`).
128
+
129
+ ## UI and languages
130
+
131
+ The UI is the same as the Go template's. `ui/app.js` registers
132
+ `window.DsmPackageApps[<package>].mount(root, ctx)`; `ctx.call(method, params)`
133
+ is the only transport and `ctx.t(key, values)` reads
134
+ `i18n/messages/<locale>.json` (`en`, `zh-TW`, `zh-CN`, `ja`, `ko`, `de`,
135
+ `fr`, `es`, `it`). Add `error.<code>` for every code your methods return.
136
+ `synopkgland check` and `build` refuse a language file with missing or extra
137
+ keys, a dropped `{placeholder}` or a listed message that drifts from the
138
+ glossary. `--ui vue` gives the Vue shell in `frontend/` instead.
139
+
140
+ ## Data, upgrades, uninstall
141
+
142
+ The App stores data in `/var/packages/__PACKAGE__/var` (`SYNOPKGLAND_DATA_DIR`,
143
+ kept across upgrades). Bump `build` for every SPK you hand out and `version`
144
+ for feature releases. Uninstalling keeps the data unless the administrator
145
+ ticks **Also delete the app data listed above** in the uninstall wizard.
146
+
147
+ ## Support matrix
148
+
149
+ | | Status |
150
+ | --- | --- |
151
+ | Build hosts | macOS, Linux |
152
+ | Target CPUs | x86_64 and aarch64 DSM models (one SPK each); armv7 is opt-in in `arches`, built but not NAS-verified |
153
+ | DSM | 7.1 or later declared (`os_min_ver`) |
@@ -0,0 +1,4 @@
1
+ {
2
+ "private": true,
3
+ "type": "module"
4
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "schema": 1,
3
+ "default": "administrators",
4
+ "methods": {}
5
+ }
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "backend",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "name": "backend",
8
+ "dependencies": {
9
+ "@synopackageland/app": "file:vendor/@synopackageland/app"
10
+ },
11
+ "devDependencies": {
12
+ "@types/node": "18.19.130",
13
+ "typescript": "5.9.3"
14
+ }
15
+ },
16
+ "node_modules/@synopackageland/app": {
17
+ "resolved": "vendor/@synopackageland/app",
18
+ "link": true
19
+ },
20
+ "node_modules/@types/node": {
21
+ "version": "18.19.130",
22
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
23
+ "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
24
+ "dev": true,
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "undici-types": "~5.26.4"
28
+ }
29
+ },
30
+ "node_modules/typescript": {
31
+ "version": "5.9.3",
32
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
33
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
34
+ "dev": true,
35
+ "license": "Apache-2.0",
36
+ "bin": {
37
+ "tsc": "bin/tsc",
38
+ "tsserver": "bin/tsserver"
39
+ },
40
+ "engines": {
41
+ "node": ">=14.17"
42
+ }
43
+ },
44
+ "node_modules/undici-types": {
45
+ "version": "5.26.5",
46
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
47
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
48
+ "dev": true,
49
+ "license": "MIT"
50
+ },
51
+ "vendor/@synopackageland/app": {
52
+ "version": "0.0.0",
53
+ "license": "MIT",
54
+ "engines": {
55
+ "node": ">=18"
56
+ }
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "backend",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "build": "tsc -p tsconfig.build.json",
7
+ "typecheck": "tsc --noEmit",
8
+ "test": "rm -rf build && tsc && node --test build/test/*.test.js"
9
+ },
10
+ "dependencies": {
11
+ "@synopackageland/app": "file:vendor/@synopackageland/app"
12
+ },
13
+ "devDependencies": {
14
+ "@types/node": "18.19.130",
15
+ "typescript": "5.9.3"
16
+ }
17
+ }
@@ -0,0 +1,5 @@
1
+ // Saves to a user's destination: `synopkgland init --with delegate` replaces this stub with an example.
2
+ import type { App } from '@synopackageland/app'
3
+ import type { Config } from './routes.js'
4
+
5
+ export function register(_app: App, _cfg: Config): void {}
@@ -0,0 +1,5 @@
1
+ // Background jobs: `synopkgland init --with jobs` replaces this stub with an example.
2
+ import type { App } from '@synopackageland/app'
3
+ import type { Config } from './routes.js'
4
+
5
+ export function register(_app: App, _cfg: Config): void {}
@@ -0,0 +1,4 @@
1
+ // The App: the runtime starts it once per call (spec/bridge/v1).
2
+ import { build, configFromEnv } from './routes.js'
3
+
4
+ await build(configFromEnv()).run()
@@ -0,0 +1,11 @@
1
+ // Request params, checked strictly: the browser sends what it likes.
2
+ import { AppError } from '@synopackageland/app'
3
+
4
+ /** params as an object with exactly these string fields, else bad_request. */
5
+ export function strings<K extends string>(params: unknown, ...names: K[]): Record<K, string> {
6
+ const ok = typeof params === 'object' && params !== null && !Array.isArray(params)
7
+ && Object.keys(params).sort().join() === [...names].sort().join()
8
+ && names.every((name) => typeof (params as Record<string, unknown>)[name] === 'string')
9
+ if (!ok) throw AppError.badRequest(`params must be {${[...names].sort().join(', ')}}`)
10
+ return params as Record<K, string>
11
+ }
@@ -0,0 +1,5 @@
1
+ // Root operations: `synopkgland init --with root-ops` replaces this stub with an example.
2
+ import type { App } from '@synopackageland/app'
3
+ import type { Config } from './routes.js'
4
+
5
+ export function register(_app: App, _cfg: Config): void {}