Work for a user while they are offline
Status: delegate is implemented; device lifecycle acceptance on real hardware is pending. The default grant allows one operation, files.moveFromStaging.
A delegate lets your package keep working for a DSM user after that user closes the browser. An administrator approves a save scope once. Each user then picks a destination folder inside that scope. From then on, your jobs can move finished files into that folder with the user’s own permissions, until the user or an administrator revokes access. The package never runs these writes as root, and it never replaces an existing file.
Add it
Section titled “Add it”Create a project with the capability, or add it to a native-go project:
synopkgland init -t native-go --with delegate my-appsynopkgland add delegateBoth write this block to dsm-package.yaml. Declaring delegate also turns on jobs.
capabilities: delegate: scope: install-selected operations: [files.moveFromStaging] destinations: [save] lifetime: until-revoked| Field | Allowed values |
|---|---|
scope | install-selected: an administrator picks home or one Shared Folder after install. home: each user’s home folder. shared:<name>: one Shared Folder, for example shared:photos. The name has no /, \, : or control characters. |
lifetime | until-revoked |
operations | [files.moveFromStaging] |
destinations | 1 to 64 unique keys. Each key matches ^[a-z][a-z0-9_-]{0,63}$. A key is a name your code uses, not a path. |
install-selected requires lifetime: until-revoked. If you set destinations or lifetime, or list files.moveFromStaging, all four fields in the table must follow these values.
add delegate does not change backend/internal/api/permissions.json, but init --with delegate does. If ordinary users must see the app after add delegate, set "app.status": "authenticated" there. The default for every method is administrators.
delegate needs a native-go project. binary and compose projects reject it. armv7 builds are not yet verified on a NAS.
What each person sees
Section titled “What each person sees”The settings page uses BackgroundSaveSettings, with one Save folder row for each destination key. The row shows the selected folder and offers Choose folder and Revoke access. The app manages its own job status and cancellation through the delegate API.
| Who | What happens |
|---|---|
Administrator, install-selected | Chooses the home scope or a Shared Folder root and enters the DSM password. One submission approves the scope through the executor root task, reads it back, and grants that root as this administrator’s save destination. |
| Ordinary user | Chooses a folder inside the approved scope through the scoped NAS picker. This creates that user’s grant after checking the live DSM session and write permission; no administrator password or second consent dialog is needed. |
User, fixed home or shared:<name> scope | Chooses a destination inside the scope fixed by the manifest. |
| Administrator, saving disabled through the API | Selects Enable background saves, then users choose their destinations again. |
Opening settings leaves existing destinations as they are. An administrator’s next scope selection uses the selected root as their own destination. Changing the confirmed scope revokes all old grants and cancels pending saves; saved files stay in place. Revoke access affects only the signed-in user’s grant.
The plain UI approves in the DSM window’s own panel, where the administrator enters the password. That panel approves without a scope, so it is offered only with home or shared:<name>, where the manifest fixes the scope. With install-selected, the approval must carry the scope the administrator picks, so use a Vue UI.
The --ui plain page has no destination picker and cannot select a scope, so init --ui plain --with delegate fails with CAPABILITY_COMBINATION_INVALID, and check refuses scope: install-selected with a plain UI. Use the Vue UI, or build the controls yourself with the methods in the next section.
The first synopkgland deploy of an install-selected project must pass the installer’s scope step: --param delegate_share=<Shared Folder> or --param delegate_home=true. Without it, deploy fails with DEPLOY_PARAM_REQUIRED before it builds. An upgrade keeps the confirmed scope and needs neither.
synopkgland status shows whether the administrator approval is ready, needs-admin-approval or blocked. With identity: standalone, use an https:// host; status reads the executor through a short-lived App session that needs HTTPS.
Use it
Section titled “Use it”backend/internal/app/delegate.go builds a delegate.Service (svc below) from the Go package github.com/synopackageland/syno-pkg-land-sdk/delegate and registers its routes. The SDK has one working producer, delegate/example.go (method delegate.example.export). BackgroundSavePanel can expose it through the opt-in sampleSave producer. A job acknowledgement does not mean the file has been saved. Copy its flow:
- While the user’s session is live, look up the destination:
svc.Destination(ctx, caller, request, "save"). KeepDestination.IDwith your own job record. - Produce the file in the service’s staging directory,
svc.StagingDir. Create it with mode0600. - Later, with no session, move it:
svc.MoveFromStaging(ctx, delegate.MoveParams{DelegationID: id, JobID: jobID, Staging: name, Name: name, Size: size}). This refuses to run as root.
selected, err := svc.Destination(ctx, caller, request, "save")// ... write the finished file into svc.StagingDir as <name> ..._, err = svc.MoveFromStaging(ctx, delegate.MoveParams{ DelegationID: selected.Destination.ID, JobID: jobID, Staging: name, Name: name, Size: size,})A repeated JobID returns the original operation instead of starting a new one. Use a new JobID for each new file.
Subfolders and name conflicts
Section titled “Subfolders and name conflicts”Name can name folders below the chosen destination, for example Album/CD1/01.flac. The move creates missing folders as the user, with the user’s own DSM permissions, so their owner and inherited ACL match a folder the user creates in File Station. Existing folders are reused, so a retry or the next file of the same album does not create duplicates. Folders are created only when the file is published: a canceled or revoked move leaves none behind.
| Rule | Value |
|---|---|
| Separator | /. No leading or trailing /, no empty, . or .. part. |
| Folder names | 1 to 255 bytes. No control characters and none of \ : * ? " < > |. Not #recycle, #snapshot or @eaDir (any case). |
| File name | The same rule as a name without folders, so existing calls behave as before. |
| Folders | At most 16 levels, so one move creates at most 16 folders. |
| Whole name | At most 1024 bytes. |
| Unicode | Stored as sent, not normalized. Send NFC if you want the name File Station shows for names typed on Windows. |
| Symlinks and mounts | A folder in the name that is a symlink, a file, or another mounted volume is refused with destination-not-directory. |
Set Conflict to choose what happens when the file name is taken:
Conflict | Effect |
|---|---|
fail or empty | Default. The move fails with destination-exists. |
overwrite | Replaces the existing file in one atomic step: the new file is written under a temporary name and then renamed over the old one. A failed move leaves the old file whole. Only a regular file the user can write is replaced; a folder, symlink or device fails with destination-exists. |
rename | Publishes name (1).ext, name (2).ext and so on, up to name (99).ext. The receipt.path reports the name used. |
A Gopeed-style download that keeps the torrent layout:
for _, f := range task.Files { // f.Path is relative, e.g. "Album/CD1/01.flac" _, err = svc.MoveFromStaging(ctx, delegate.MoveParams{ DelegationID: selected.Destination.ID, JobID: task.ID + "-" + strconv.Itoa(f.Index), Staging: f.StagingName, Name: task.Name + "/" + f.Path, Size: f.Size, Conflict: "rename", })}Your UI calls these backend methods with ctx.call. Every method needs a signed-in user with a live DSM session, otherwise it returns session_required.
| Method | Who | Result |
|---|---|---|
delegate.status | user | state (ready, needs-admin-approval, blocked, needs-scope-confirmation or disabled), disabled, lifetime, destinations, scope_mode, and for install-selected: preselection, selection, mismatch |
delegate.destination | user | {key, selection, destination, status}. status is active, missing, revoked (the grant for the key was revoked and no other remains), reselection-required, needs-scope-confirmation or disabled. |
delegate.select_destination | user | Takes {key, path}. Checks that the user can write there, then creates the grant. A folder the user cannot write to returns PERMISSION_DENIED. |
delegate.move_from_staging | user | Takes {delegation_id, job_id, staging, name, size, conflict?}. name may contain folders. Returns {job_id, state}. The caller must own the grant. |
delegate.operations.list | user | Takes {}. Returns the user’s saves. A save the executor has admitted but not started is queued; uncertain means the executor cannot tell whether it was admitted. |
delegate.operations.get, delegate.operations.cancel | user | Take {delegation_id, job_id}. Return {job_id, state, receipt?, code?, reason?}. |
delegate.disable, delegate.enable | administrator | Take {}. Both revoke all grants of the app. |
delegate.audit | user | Administrators see all events of the app. Other users see only their own. At most 256 events. |
delegate.example.export | user | Takes {key, job_id}. Runs the example producer. |
A save ends as committed with a receipt ({path, size}), canceled, interrupted or uncertain. Do not retry an uncertain or interrupted save automatically: check the destination first. A cancel can report the reasons operation-canceled-by-user, grant-revoked, scope-changed, integration-disabled or destination-replaced. When the executor stops a save itself, the reason is grant-revoked, validation-denied, integration-disabled or storage-error, and the executor removes the partial file even after the user lost write access.
Combine with a daemon
Section titled “Combine with a daemon”A daemon can run next to jobs only when the delegate uses lifetime: until-revoked. See Manifest reference for the daemon fields.
Time-limited grants
Section titled “Time-limited grants”If you set none of destinations, lifetime and files.moveFromStaging, the delegate runs in a time-limited mode. Then operations is any unique subset of the file operations below, and scope is home or shared:<name>. delegate.create takes a ttl_seconds from 1 to 3600 and the operations of that one grant. The generated worker handles only download and SHA-256 jobs, and downloads are limited to 64 MiB and 60 seconds. The default grant does not allow these file operations: calls to them return POLICY_DENIED.
File operations
Section titled “File operations”A job calls them through job.Files(). Every path must be inside the folder of the grant. The executor checks each path before the call, and checks again the paths that File Station reports back. For every operation except files.write and files.read, the executor calls File Station through synowebapi as the user, so DSM applies that user’s own permissions. Only a fixed allowlist of File Station methods can be called this way.
| Operation | Go method | What it does | DSM permission the user needs |
|---|---|---|---|
files.list, files.info | List, Info | Lists a folder, or returns one entry. Each entry includes its size. | Read |
files.createFolder | CreateFolder, CreateFolderAll | Creates a folder. CreateFolderAll also creates missing parent folders, like mkdir -p. | Write on the parent |
files.checkPermission | CheckPermission | Checks that the user can write to a folder. It writes and removes a probe file with a fresh random name, .synopkgland-permission-probe-<32 hex>. | None |
files.write | Write | Saves at most 64 MiB from staging as a new file. | Write |
files.read | Read | Returns a regular file of at most 64 MiB. The executor creates the staging folder if needed and copies the file into it as the user, without following symlinks. | Read |
files.rename | Rename | Renames a file or folder in place. | Modify |
files.copy | Copy | Starts copying a file or folder into another folder. | Read on the source, write on the destination |
files.move | Move | Starts moving a file or folder into another folder. | Delete on the source, write on the destination |
files.delete | Delete | Starts deleting a file or folder. | Delete |
Rules:
- Declare each operation you use. A write operation never implies
files.delete, so an app that only saves files cannot delete them. An operation that the approved policy does not list returnsPOLICY_DENIED. An operation that the grant does not list returnsDELEGATION_DENIED. - The folder of the grant itself cannot be renamed, copied, moved or deleted (
SCOPE_DENIED). A path outside the folder or through a symlink isSCOPE_DENIEDtoo; a path inside it that does not exist isnot-found. CopyandMoverefuse a name that is already taken.delegate.ConflictOverwritereplaces it. They have norenamepolicy.Deleteof a folder needsRecursive: true, otherwise it returnsrecursive-required. Deleted items go to the recycle bin of the Shared Folder. If that Shared Folder has no recycle bin,Deletereturnsrecycle-bin-disabledunless you setPermanent: true. When the recycle bin is on, DSM keeps the item there even withPermanent: true.
Long-running file operations
Section titled “Long-running file operations”Copy, Move and Delete start a File Station background task and return a TaskState with a TaskID. Call TaskStatus until State is finished, failed (with Error) or canceled. Progress is a fraction from 0 to 1. Once a task has ended, TaskStatus keeps returning the same final state. CancelTask stops the task. Only the job that started a task can follow it. Revoking the grant stops its running tasks.
files := job.Files()task, err := files.Move(ctx, "/home/Downloads/album", "/home/Music", "")for err == nil && task.State == "running" { time.Sleep(time.Second) task, err = files.TaskStatus(ctx, task.TaskID)}File Station reports a refusal per item, not only for the whole task. The executor reads those and returns the item’s code: no permission is PERMISSION_DENIED, a taken name is destination-exists, a missing path is not-found, a name the volume refuses is name-too-long, a full volume is NO_SPACE, and CreateFolder below a file is destination-not-directory. These codes are not retryable. A delete that leaves the item behind fails with PERMISSION_DENIED or DSM_FAILED.
The executor tracks at most 64 tasks while it runs. After the package restarts, TaskStatus returns task-not-found. State is unknown when DSM has dropped the task; then check the result with Info. Whether DSM reports a task to a later synowebapi process is not yet verified on a NAS.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| File size moved | 16 GiB |
| Queued saves | 16 |
| Concurrent copies | 1 |
| One copy attempt | 24 hours |
| Destination keys | 1 to 64 |
| Existing destination file | Replaced only with conflict: overwrite |
Folders in name | 16 levels, 1024 bytes in total |
File read by files.read | 64 MiB |
| File Station tasks tracked | 64 |
conflict: rename alternatives | name (1) to name (99) |
| Backend | native-go only |
| Architectures | x86_64, aarch64 and armv7 (armv7 not yet verified on a NAS) |
Audit events returned by delegate.audit | 256 |
| Approval on DSM 7.1 | Uses older scheduler APIs; see Root operations, DSM 7.1 |
Lifecycle effects:
| Event | Effect |
|---|---|
| Stop the package | Saving pauses. Approval, grants and the confirmed scope are kept. |
| Upgrade | Saving resumes only if the approved policy still matches. A new executor that matches the INFO anchor needs no approval. Otherwise the app stays at needs-admin-approval. A copy in progress ends as interrupted. |
| Uninstall | Removes the approval, grants, confirmed scope, root tasks and the app’s Application Privileges rules, and stops a portal address. A reinstall needs a new approval. See Data and removal. |
Change scope, destinations, operations or lifetime, or the entry field | The administrator must approve again. |
Troubleshooting
Section titled “Troubleshooting”| Symptom or code | Cause | Fix |
|---|---|---|
needs-admin-approval | No administrator approval yet, or the approved policy changed. | An administrator opens settings and approves again. |
needs-scope-confirmation | scope: install-selected and no scope is confirmed. | An administrator chooses the save-folder scope in settings and confirms it with the DSM password. |
disabled | An administrator disabled background saves. | An administrator selects Enable background saves. Users choose destinations again. |
revoked | The grant for this destination was revoked. | The user chooses the destination again. |
reselection-required | The scope changed, or the grant does not match the confirmed scope. | The user chooses the destination again. |
invalid-destination | The key is not in destinations, or the user has no active grant for it. | Fix the key, or let the user choose a destination. |
destination-exists | A file with that name already exists in the folder. | Use a new file name with a new JobID, or set conflict. |
invalid-path, path-too-deep | A folder in name breaks the rules above, or there are more than 16. | Fix the name; use a new JobID. |
destination-not-directory | A folder in name exists as a file, a symlink or another volume. | Choose another folder name. |
name-too-long | The volume refused a name as too long, for example 143 bytes on an encrypted Shared Folder. | Use a shorter name. |
PERMISSION_DENIED | The user cannot write to the destination folder. | Fix the folder permission, then the user selects the destination again. |
NO_SPACE | The volume is full. | Free space, then start a new job. |
queue-full | 16 saves are already queued. | Wait for saves to finish. |
recursive-required | Delete was called on a folder without Recursive. | Set Recursive: true. |
recycle-bin-disabled | The Shared Folder has no recycle bin. | Turn on its recycle bin in DSM, or set Permanent: true. |
not-found, not-a-file | The path does not exist, or Read met a folder or a special file. | Check the path. |
task-not-found, too-many-tasks | The task belongs to another job or was lost in a restart, or 64 tasks are tracked. | Check the result with Info, or wait for tasks to finish. |
EXECUTOR_PAUSED | The package is stopped, or an upgrade has not been approved. | Start the package, or approve again. |
session_required | No live DSM session. | The user signs in to DSM again. |
admin_required | An administrator-only method was called by another user. | Call it as an administrator. |
Other codes are listed in Errors.