Native apps
A native app is a Go backend that DSM runs as a CGI, plus a UI that opens in its own DSM window. It installs as an unsigned SPK, runs as its own package account, needs no containers and has no root access by default. synopkgland init -t native-go creates a working project: sign-in status, a greeting that administrators can edit, and the build wiring.
Set it up
Section titled “Set it up”You need Node.js with the synopkgland CLI and Go 1.25 or later.
synopkgland init -t native-go my-appThe command refuses to overwrite files (INIT_TARGET_NOT_EMPTY). The default UI is Vue. Add --ui plain for a hand-written ui/app.js, see UI components. Add --with files,jobs,login for capabilities, see Capabilities.
| Path | What it is | Edit it? |
|---|---|---|
dsm-package.yaml | Package identity, version, build number, architectures, backend and UI location | Yes |
backend/internal/app/ | Your API: routes and handlers | Yes |
backend/internal/api/permissions.json | Who may call each method | Yes |
frontend/src/ | The Vue UI shell. Its text is in frontend/src/i18n/<locale>.json | Yes |
i18n/messages/<locale>.json | Package text in nine languages | Yes |
i18n/glossary.json | DSM’s own words and the messages that must use them | Add terms |
AGENTS.md, CLAUDE.md | Instructions for an AI assistant working in the project | Yes |
backend/internal/api/permissions.go, backend/cmd/ | Permission loading and wiring of the CGI and the jobs worker | Rarely |
dist/ | Built SPK files | No |
Request guards, the DSM sign-in check, File Station, jobs and delegate come from the SDK, not from project files: the Go module github.com/synopackageland/syno-pkg-land-sdk and the npm package @synopackageland/sdk. The require github.com/synopackageland/syno-pkg-land-sdk line in backend/go.mod is the project’s SDK version. frontend/package.json must pin @synopackageland/sdk to the same version, or check fails with SDK_VERSION_MISMATCH. A CLI builds only SDKs of its own minor version (SDK_UNSUPPORTED).
With --ui plain the project has ui/app.js and ui/app.css instead of frontend/. The build generates everything else: INFO, privileges (run-as: package), lifecycle scripts, icons, the window loader and the CGI.
To package a prebuilt executable instead of Go source, use -t binary. A binary backend cannot use files, jobs, login or delegate. To keep a product that already has its own payload and lifecycle, see Existing native products.
Add a backend route
Section titled “Add a backend route”A route has two parts: a Go handler registered in backend/internal/app/routes.go, and an entry in backend/internal/api/permissions.json.
func Routes(cfg Config) []api.Route { routes := cfg.delegateRoutes() routes = append(routes, []api.Route{ api.Handle("app.status", cfg.status), api.Handle("greeting.get", cfg.getGreeting), api.Handle("greeting.set", cfg.setGreeting), api.Handle("notes.list", cfg.listNotes), }...) // ...the rest of the function stays as generated}
func (cfg Config) listNotes(_ context.Context, caller api.Caller, params json.RawMessage) (any, error) { var in struct { Limit int `json:"limit"` } if err := decodeParams(params, &in); err != nil { return nil, err } if in.Limit < 1 || in.Limit > 100 { return nil, api.BadRequest("limit must be 1 to 100") } return map[string]any{"owner": caller.User(), "notes": []string{}}, nil}{ "schema": 1, "default": "administrators", "methods": { "notes.list": "authenticated" }}Then add valid parameters for the route to sampleParams in backend/internal/app/routes_test.go, for example the key notes.list with the value {"limit":20}. The generated tests fail until you do. Run them with (cd backend && go test ./...).
Rules for routes:
- A handler is
func(ctx, caller, params) (any, error).decodeParamsrejects unknown fields. Returnapi.BadRequest(message)or&api.Error{Code, Message}. Any other error reaches the UI asinternal. - Use
api.HandleRequestwhen the handler needs the HTTP request,api.HandleResponseto set cookies or write the response, andapi.HandleStreamfor a transfer. caller.User()is the signed-in DSM account,caller.Admin()is true for members of the administrators group. Both come from DSM, never from the request.- Inside a route that ordinary users share,
api.RequireAdmin(caller)reserves one action for administrators. It returnsadmin_required. - Method names are lowercase words joined by dots, for example
notes.list. A name listed inpermissions.jsonmust be registered. - Changing
permissions.jsonneeds a rebuild. Requests cannot change it.
Level in permissions.json | Without a session | Ordinary user | Administrator |
|---|---|---|---|
administrators (also the result for an unlisted method) | session_required | admin_required | runs |
authenticated | session_required | runs | runs |
public | runs | runs | runs |
default must stay administrators. Use public only for routes that must work without signing in. A route that is authenticated or public also lets ordinary users open the app window, see entry below.
Who can open the app
Section titled “Who can open the app”The optional entry field in dsm-package.yaml adds a check before every route.
entry | Who gets in | Refusal code |
|---|---|---|
| omitted | The window follows your routes: it is visible to all users when at least one route, including those added by files or jobs, is not administrators | per route |
admin-only | Administrators only | admin_required |
app-privilege | Administrators, plus accounts allowed in Control Panel > Application Privileges | app_privilege_denied |
app-privilege needs at least one authenticated route. public routes skip the check. If DSM cannot answer the privilege question, the call fails with auth_unavailable.
Call it from the UI
Section titled “Call it from the UI”The UI receives a context object ctx. ctx.call(method, params) posts to your backend with the signed-in DSM session and resolves with the result. Errors reject with { code, message }.
try { const { notes } = await ctx.call<{ notes: string[] }>('notes.list', { limit: 20 }) console.log(notes)} catch (error) { const { code } = error as { code?: string } // show a message for `code`, for example admin_required or bad_request}Show administrator-only controls with ctx.user?.isAdmin. That only hides controls. The backend decides who may call. Other context members: ctx.reopen(fn) runs fn each time DSM shows the window again and returns an unsubscribe function, ctx.locale is the window language.
All replies use HTTP 200. A failure is {"success": false, "error": {"code", "message"}}, like DSM’s own web API. The error codes your UI sees are listed in Errors.
Translate the app
Section titled “Translate the app”The app ships in nine languages: en, zh-TW, zh-CN, ja, ko, de, fr, es and it. DSM’s other languages show English. The window follows DSM’s language setting.
i18n/messages/<locale>.jsonholds flat"dotted.key": "text"pairs.en.jsonis required. Every language file needs exactly the keys ofen.jsonand the same{placeholder}names.- To add a message, add the key to
en.jsonand to the other eight files. - Add an
error.<code>message for every error code your backend returns, so the UI can show text in the user’s language. i18n/glossary.jsonlists DSM’s own words (“Save”, “Cancel”) in all nine languages and which messages must contain them. To use a DSM term, copy its text and add your key to the term’skeys.i18n.infoindsm-package.yamlsets the display name and description that Package Center shows per language. English comes from the top-level fields.- In a plain UI,
ctx.t("greeting.setBy", { text, user })reads these files. The Vue UI keeps its on-screen text infrontend/src/i18n/<locale>.json. Add new text to all nine files there.
check and build fail on a missing or extra key, a dropped placeholder, an unsupported language, or a message that drifts from the glossary.
Develop, deploy, check
Section titled “Develop, deploy, check”synopkgland checksynopkgland buildsynopkgland deploy --host my-nassynopkgland status --host my-nassynopkgland logs --host my-nas| Command | What it does |
|---|---|
check | Validates the manifest, capability combinations, permissions.json and translations |
build | Writes dist/<package>-<version>-<build>-<arch>.spk for each architecture. In a Vue project, run npm install in frontend/ first |
deploy | Builds one architecture and installs it on the NAS. The version-build combination must be higher than the installed one (DEPLOY_VERSION_INVALID). Use --arch if the CPU type cannot be detected (DEPLOY_ARCH_UNDETERMINED) |
status | Package state, the URL that opens the app, and the readiness of each capability |
logs | Package status lines and the package identity. It is not an application log |
--host is required for deploy, status and logs (HOST_REQUIRED). Bump build for every SPK you hand out. See Ship for installing, upgrades and publishing. --json on check, build, deploy and status prints one JSON object.
Limits
Section titled “Limits”| Item | Limit |
|---|---|
| Package name | 2 to 64 letters and digits, starting with a letter |
build | Integer from 1 to 9999 |
| JSON request body | 1 MiB |
| Time for one request | 20 seconds |
| Architectures | x86_64, aarch64, armv7 (armv7 jobs, delegate, root operations and daemon are not yet verified on a NAS) |
| Minimum DSM | os_min_ver defaults to 7.1-42661 |
| Languages | Nine, see above |
Troubleshooting
Section titled “Troubleshooting”| Symptom or code | Cause | Fix |
|---|---|---|
admin_required for an ordinary user | The route is administrators or not listed in permissions.json | Open the app with an administrator account. If the method is intended for ordinary users, set "authenticated" for it and rebuild. |
unknown_method | The method is not registered in routes.go | Register it with api.Handle |
bad_request | decodeParams rejected an unknown field or the handler rejected a value | Send exactly the fields the handler reads |
session_required | No valid DSM session | Sign in to DSM again |
app_privilege_denied | entry: app-privilege and the account has no Application Privileges for the app | Ask an administrator to open Control Panel > Application Privileges, select the app and click Edit, then allow your account or group. |
INIT_TARGET_NOT_EMPTY | init found an existing file | Use an empty directory |
| Go tests fail after adding a route | The route is missing from sampleParams | Add valid parameters |
Related
Section titled “Related”An admin-only app is available only to members of the DSM administrators group. Open it with an administrator account; it is not listed in Application Privileges. For an app_privilege_denied refusal, use the app’s display name in the Application Privileges list. SDK denial views distinguish these two codes; other permission errors keep their own guidance.