@synopackageland/cli 0.40.0 → 0.41.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.
- package/CHANGELOG.md +29 -0
- package/README.md +23 -2
- package/dist/cli.js +4 -0
- package/dist/ctl/cli.d.ts +14 -11
- package/dist/ctl/cli.js +4 -1
- package/dist/ctl/descriptor.d.ts +3 -0
- package/dist/ctl/descriptor.js +9 -0
- package/dist/ctl/external.d.ts +27 -0
- package/dist/ctl/external.js +195 -0
- package/dist/error-registry.js +5 -0
- package/dist/nas/store.d.ts +3 -1
- package/dist/nas/store.js +2 -2
- package/dist/standalone/api-description.js +12 -1
- package/dist/standalone/dsm-window.js +14 -3
- package/dist/standalone/host-adapter.d.ts +1 -1
- package/dist/standalone/host-adapter.js +13 -0
- package/dist/standalone/host-types.d.ts +1 -0
- package/dist/standalone/manifest.d.ts +3 -0
- package/dist/standalone/manifest.js +15 -1
- package/docs/ctl-external.md +212 -0
- package/package.json +3 -2
- package/schema/synopkgland-api-v1.schema.json +23 -0
- package/skills/SKILL.md +13 -2
- package/skills/reference/create.md +15 -4
- package/skills/reference/ctl.md +28 -0
- package/skills/reference/ui-vue.md +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.41.1 (2026-10-29)
|
|
6
|
+
|
|
7
|
+
- `ui.banner: shown | hidden` lets an App choose whether DSM draws its banner
|
|
8
|
+
(App name, account menu) above the App's standalone page. `shown`, the
|
|
9
|
+
default, keeps today's page. `hidden` drops it for this App only, as DSM's
|
|
10
|
+
global "Hide banner" setting does, without touching that setting or a
|
|
11
|
+
desktop window's title bar. Meant for Apps whose own frame names the App and
|
|
12
|
+
shows the account; the SDK's `AppShell` then shows the account and a Sign
|
|
13
|
+
out button. The host adapter's new `ctx.host.signOut()` signs out of DSM the
|
|
14
|
+
way the banner's menu does. `STANDALONE_UI_BANNER_INVALID` refuses other values.
|
|
15
|
+
|
|
16
|
+
- Sync the bundled skill with SDK/App 0.38.0: 300-second requests, 302-second
|
|
17
|
+
browser waits and existing background admission, progress and status polling.
|
|
18
|
+
Preserve explicit budgets, original job IDs/keys and ctl's 62-second transport.
|
|
19
|
+
- Select templates 0.35.1 with SDK/App 0.38.0 for new projects.
|
|
20
|
+
|
|
21
|
+
## 0.41.0 (2026-10-29)
|
|
22
|
+
|
|
23
|
+
- Add generic `ctl external --protocol <id> --host <profile> --input - --json`
|
|
24
|
+
for local MCP bridges. Apps opt in with `ctl.external` and a dedicated
|
|
25
|
+
administrator RPC entry. Each request checks the stored login, pinned host,
|
|
26
|
+
installed package and fresh description; calls also check the current catalog
|
|
27
|
+
revision and schema. The App verifies pins/revision at dispatch and creates
|
|
28
|
+
caller authority and MCP provenance. Ordinary ctl cannot invoke the external
|
|
29
|
+
entry. Lost mutation replies are never retried. See
|
|
30
|
+
[the external contract](docs/ctl-external.md) for App integration requirements.
|
|
31
|
+
|
|
3
32
|
## 0.40.0 (2026-10-29)
|
|
4
33
|
|
|
5
34
|
- `daemon.backend: true` runs the App's own native backend
|
package/README.md
CHANGED
|
@@ -61,6 +61,10 @@ from `target/app` as the package account for each request (see `skills/reference
|
|
|
61
61
|
Application Privileges admission, explicit method policies, gateway conflicts and
|
|
62
62
|
the build-bound SDK checks.
|
|
63
63
|
|
|
64
|
+
[External callers](docs/ctl-external.md) describes the local MCP bridge command,
|
|
65
|
+
App-declared protocols and required trusted administrator entry. This opt-in
|
|
66
|
+
contract does not add an MCP server or automatically wire an App's tool Engine.
|
|
67
|
+
|
|
64
68
|
## SDK and templates
|
|
65
69
|
|
|
66
70
|
The CLI packages, deploys and checks. The app-side code is the SDK
|
|
@@ -78,7 +82,7 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
|
|
|
78
82
|
packages. Private mirrors can use explicitly configured Go authentication and
|
|
79
83
|
checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
|
|
80
84
|
|
|
81
|
-
CLI 0.
|
|
85
|
+
CLI 0.41.1 uses templates ~0.35.1, whose default SDK pin is 0.38.0. Compose import and SDK defaults
|
|
82
86
|
read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
|
|
83
87
|
Every App method must be declared in the permissions table. Dynamic names and
|
|
84
88
|
custom HTTP dispatchers are rejected. With SDK 0.33.0, use fixed
|
|
@@ -89,7 +93,7 @@ unimported development commands are excluded. Use
|
|
|
89
93
|
authentication. See [App entry and registration](docs/app-entry.md).
|
|
90
94
|
|
|
91
95
|
`init` and `add` read their templates from the locally installed
|
|
92
|
-
`@synopackageland/templates`. CLI 0.
|
|
96
|
+
`@synopackageland/templates`. CLI 0.41.1 depends on `~0.35.1`: npm installation or
|
|
93
97
|
update can select the latest templates patch in the 0.35 minor line. Templates
|
|
94
98
|
patches need only a templates release; update the npm installation to receive them.
|
|
95
99
|
The CLI never fetches templates at runtime, so offline use works after installation;
|
|
@@ -328,6 +332,23 @@ a one-line reload notice. The wrapper sets the standalone
|
|
|
328
332
|
page title, favicon and device-width viewport. It retains ExtJS; `pureEnv` is
|
|
329
333
|
not appropriate, and alternate Portal ports are not needed (`allowAltPort` is omitted).
|
|
330
334
|
|
|
335
|
+
`ui.banner: shown | hidden` chooses whether DSM draws its banner (the App's
|
|
336
|
+
name and the account menu) above the App's standalone page. The default,
|
|
337
|
+
`shown`, keeps DSM's behavior. `hidden` drops it for this App only, with the
|
|
338
|
+
class DSM's global "Hide banner" setting uses, and leaves DSM's setting and a
|
|
339
|
+
DSM desktop window's title bar alone. Hide it when the App's own frame already
|
|
340
|
+
names the App and shows the account, for example a full-page web App built on
|
|
341
|
+
the SDK's `AppShell`: the shell then shows the signed-in account and a Sign out
|
|
342
|
+
button (`ctx.host.signOut()`, DSM's banner sign-out). Keep the banner when the
|
|
343
|
+
App has no account area of its own.
|
|
344
|
+
|
|
345
|
+
```yaml
|
|
346
|
+
ui:
|
|
347
|
+
dir: ui/dist
|
|
348
|
+
launch: new-page
|
|
349
|
+
banner: hidden
|
|
350
|
+
```
|
|
351
|
+
|
|
331
352
|
A full page does not need Application Portal. Omit `portal` unless an alias is
|
|
332
353
|
explicitly wanted; only that opt-in adds `defaultAlias`, login styling, Portal
|
|
333
354
|
panels and capabilities. No Portal is a healthy default for inspect/info/doctor.
|
package/dist/cli.js
CHANGED
|
@@ -297,6 +297,10 @@ async function containerBuild(project, arches, options) {
|
|
|
297
297
|
}
|
|
298
298
|
/** Resolves after dispatch; commands may set process.exitCode. Rejections are failures. */
|
|
299
299
|
export async function runCli(argv) {
|
|
300
|
+
if (argv[2] === 'ctl' && argv[3] === 'external') {
|
|
301
|
+
const { runCtl } = await import('./ctl/cli.js');
|
|
302
|
+
return runCtl(argv.slice(3));
|
|
303
|
+
}
|
|
300
304
|
const { args, options } = nasFlags(argv, argv[2] !== "ctl");
|
|
301
305
|
return withNasOptions(options, () => runCliWithin(args));
|
|
302
306
|
}
|
package/dist/ctl/cli.d.ts
CHANGED
|
@@ -10,17 +10,6 @@ export interface CtlIO {
|
|
|
10
10
|
requestFor?: (host: Host) => JsonRequest;
|
|
11
11
|
}
|
|
12
12
|
export declare function listView(host: string, apps: Descriptor[]): {
|
|
13
|
-
host: string;
|
|
14
|
-
package: {
|
|
15
|
-
id: string;
|
|
16
|
-
version: string;
|
|
17
|
-
displayName: string;
|
|
18
|
-
};
|
|
19
|
-
aliases: string[];
|
|
20
|
-
protocolVersion: 1;
|
|
21
|
-
developedBy: string;
|
|
22
|
-
provenance: string;
|
|
23
|
-
hash: string;
|
|
24
13
|
commands: {
|
|
25
14
|
parameters: string | Record<string, any>;
|
|
26
15
|
examples: string[];
|
|
@@ -33,5 +22,19 @@ export declare function listView(host: string, apps: Descriptor[]): {
|
|
|
33
22
|
flow: import("./descriptor.js").Flow;
|
|
34
23
|
id: string;
|
|
35
24
|
}[];
|
|
25
|
+
external?: Record<string, {
|
|
26
|
+
method: string;
|
|
27
|
+
}> | undefined;
|
|
28
|
+
host: string;
|
|
29
|
+
package: {
|
|
30
|
+
id: string;
|
|
31
|
+
version: string;
|
|
32
|
+
displayName: string;
|
|
33
|
+
};
|
|
34
|
+
aliases: string[];
|
|
35
|
+
protocolVersion: 1;
|
|
36
|
+
developedBy: string;
|
|
37
|
+
provenance: string;
|
|
38
|
+
hash: string;
|
|
36
39
|
}[];
|
|
37
40
|
export declare function runCtl(argv: string[], io?: CtlIO): Promise<void>;
|
package/dist/ctl/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { selectHosts, selectApp, selectOperation, flagName, RESERVED_FLAGS } fro
|
|
|
10
10
|
import { canonicalOrigin, CTL_TIMEOUT_MS, createRequest } from './transport.js';
|
|
11
11
|
import { loginDsm, logoutDsm } from './dsm-auth.js';
|
|
12
12
|
import { publicOperation } from './redaction.js';
|
|
13
|
+
import { runExternal } from './external.js';
|
|
13
14
|
const USAGE = 'synopkgland host add|list|show|login|logout|remove|revoke|default|trust; ctl list [--host a,b|--all-hosts] [--app pkg] [--refresh]; ctl <alias|package> <command...> [typed flags|--input file|-] [--dry-run] [--yes]; NAS HTTPS accepts any certificate by default; --strict-tls or --ca <file> opts into verification';
|
|
14
15
|
async function prompt(message, secret = false) {
|
|
15
16
|
if (!process.stdin.isTTY)
|
|
@@ -59,8 +60,10 @@ function example(host, app, op) {
|
|
|
59
60
|
return `printf '%s' ${q(JSON.stringify(params))} | ${base} --input -` + (op.effect === 'read' ? '' : ' --yes');
|
|
60
61
|
});
|
|
61
62
|
}
|
|
62
|
-
export function listView(host, apps) { return apps.map(app => ({ host, package: app.payload.package, aliases: app.payload.aliases, protocolVersion: app.version, developedBy: app.payload.provenance.developedBy, provenance: 'self-declared', hash: app.sha256, commands: Object.entries(app.payload.commands).map(([id, original]) => { const op = publicOperation(original); return { id, ...op, parameters: op.paramsSchema ?? 'Undescribed parameters; use --input', examples: example(host, app, op) }; }) })); }
|
|
63
|
+
export function listView(host, apps) { return apps.map(app => ({ host, package: app.payload.package, aliases: app.payload.aliases, protocolVersion: app.version, developedBy: app.payload.provenance.developedBy, provenance: 'self-declared', hash: app.sha256, ...(app.payload.external ? { external: app.payload.external } : {}), commands: Object.entries(app.payload.commands).map(([id, original]) => { const op = publicOperation(original); return { id, ...op, parameters: op.paramsSchema ?? 'Undescribed parameters; use --input', examples: example(host, app, op) }; }) })); }
|
|
63
64
|
export async function runCtl(argv, io = {}) {
|
|
65
|
+
if (argv[0] === 'external')
|
|
66
|
+
return runExternal(argv.slice(1), io);
|
|
64
67
|
try {
|
|
65
68
|
const { args, options } = nasFlags(argv);
|
|
66
69
|
return await withNasOptions({ ...options, warn: io.err ?? console.error }, () => runCtlWithin(args, io));
|
package/dist/ctl/descriptor.d.ts
CHANGED
package/dist/ctl/descriptor.js
CHANGED
|
@@ -27,6 +27,13 @@ export function validateDescriptor(value, id, version) {
|
|
|
27
27
|
return fail('Invalid command allowlist.');
|
|
28
28
|
if (!p.bindings || typeof p.bindings !== 'object')
|
|
29
29
|
return fail('Missing internal method bindings.');
|
|
30
|
+
const externalMethods = new Set();
|
|
31
|
+
for (const entry of Object.values(p.external ?? {})) {
|
|
32
|
+
const binding = p.bindings[entry.method];
|
|
33
|
+
if (!binding || binding.permission !== 'administrators' || binding.effect !== 'destructive' || entry.method.startsWith('identity.') || entry.method === DESCRIPTION_METHOD)
|
|
34
|
+
return fail('External entries require dedicated administrator bindings.');
|
|
35
|
+
externalMethods.add(entry.method);
|
|
36
|
+
}
|
|
30
37
|
const paths = new Set();
|
|
31
38
|
for (const [method, raw] of Object.entries(p.commands)) {
|
|
32
39
|
const op = raw;
|
|
@@ -38,6 +45,8 @@ export function validateDescriptor(value, id, version) {
|
|
|
38
45
|
catch {
|
|
39
46
|
return fail('Invalid command flow or method bindings.');
|
|
40
47
|
}
|
|
48
|
+
if (externalMethods.has(method) || flowMethods(op.flow).some(name => externalMethods.has(name)))
|
|
49
|
+
return fail('External entries cannot be ordinary commands or flow helpers.');
|
|
41
50
|
const path = op.cli.join(' ');
|
|
42
51
|
if (paths.has(path))
|
|
43
52
|
return fail('Duplicate CLI command path.');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CtlStore, type Host } from './store.js';
|
|
2
|
+
import { type JsonRequest } from './transport.js';
|
|
3
|
+
import type { CtlIO } from './cli.js';
|
|
4
|
+
type Describe = {
|
|
5
|
+
protocol: string;
|
|
6
|
+
action: 'describe';
|
|
7
|
+
};
|
|
8
|
+
type Call = {
|
|
9
|
+
protocol: string;
|
|
10
|
+
action: 'call';
|
|
11
|
+
revision: string;
|
|
12
|
+
method: string;
|
|
13
|
+
arguments: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
export type ExternalRequest = Describe | Call;
|
|
16
|
+
export declare function parseExternalRequest(raw: string, protocol: string): ExternalRequest;
|
|
17
|
+
/** A separate entry, never an ordinary ctl flow with relabeled provenance. */
|
|
18
|
+
export declare class ExternalClient {
|
|
19
|
+
private readonly store;
|
|
20
|
+
private readonly host;
|
|
21
|
+
private readonly request;
|
|
22
|
+
private readonly deadline;
|
|
23
|
+
constructor(store: CtlStore, host: Host, request?: JsonRequest);
|
|
24
|
+
execute(protocol: string, input: ExternalRequest): Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
export declare function runExternal(argv: string[], io?: CtlIO): Promise<void>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { CtlError, ctlError } from './errors.js';
|
|
2
|
+
import { readBounded, strictJson } from './json.js';
|
|
3
|
+
import { ajv, safeSchema, validateDescriptor } from './descriptor.js';
|
|
4
|
+
import { CtlStore } from './store.js';
|
|
5
|
+
import { CtlClient } from './service.js';
|
|
6
|
+
import { canonicalOrigin, createRequest } from './transport.js';
|
|
7
|
+
import { nasOptions } from '../nas/options.js';
|
|
8
|
+
import { SensitiveOutput } from './redaction.js';
|
|
9
|
+
const LIMIT = 1048576;
|
|
10
|
+
const PROTOCOL = /^[a-z][a-z0-9-]{0,63}$/;
|
|
11
|
+
const METHOD = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
|
|
12
|
+
const AUTHORITY_KEYS = ['actor', 'via', 'allowMutation', 'allow_mutation', 'host'];
|
|
13
|
+
const USAGE = 'synopkgland ctl external --protocol <declared-protocol> --host <profile> --input - --json';
|
|
14
|
+
const HINT = 'Check local login, administrator access, App approval and tool revision. Query original state before repeating a mutation.';
|
|
15
|
+
function fail(code, message, exit = 4) { throw new CtlError(code, message, HINT, exit); }
|
|
16
|
+
function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
17
|
+
function revision(value) { return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value); }
|
|
18
|
+
export function parseExternalRequest(raw, protocol) {
|
|
19
|
+
try {
|
|
20
|
+
if (!PROTOCOL.test(protocol) || Buffer.byteLength(raw) > LIMIT)
|
|
21
|
+
throw Error();
|
|
22
|
+
const value = strictJson(raw);
|
|
23
|
+
if (!object(value) || value.protocol !== protocol)
|
|
24
|
+
throw Error();
|
|
25
|
+
const keys = value.action === 'describe' ? ['protocol', 'action'] : ['protocol', 'action', 'revision', 'method', 'arguments'];
|
|
26
|
+
if (Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key)))
|
|
27
|
+
throw Error();
|
|
28
|
+
if (value.action === 'describe')
|
|
29
|
+
return value;
|
|
30
|
+
if (value.action !== 'call' || !revision(value.revision) || typeof value.method !== 'string' || value.method.length > 128 || !METHOD.test(value.method) || !object(value.arguments) || AUTHORITY_KEYS.some(key => Object.hasOwn(value.arguments, key)))
|
|
31
|
+
throw Error();
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return fail('CTL_EXTERNAL_INPUT', 'Invalid external request.', 3);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function validateCatalog(value) {
|
|
39
|
+
if (!object(value) || !revision(value.revision) || !Array.isArray(value.tools) || value.tools.length > 1024)
|
|
40
|
+
return fail('CTL_EXTERNAL_CATALOG', 'Invalid external tool catalog.', 5);
|
|
41
|
+
const methods = new Set(), names = new Set();
|
|
42
|
+
for (const tool of value.tools) {
|
|
43
|
+
if (!object(tool) || typeof tool.name !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(tool.name) ||
|
|
44
|
+
!object(tool._meta) || typeof tool._meta.method !== 'string' || tool._meta.method.length > 128 || !METHOD.test(tool._meta.method) ||
|
|
45
|
+
methods.has(tool._meta.method) || names.has(tool.name) || !object(tool.inputSchema) || !object(tool.outputSchema))
|
|
46
|
+
return fail('CTL_EXTERNAL_CATALOG', 'Invalid external tool metadata.', 5);
|
|
47
|
+
methods.add(tool._meta.method);
|
|
48
|
+
names.add(tool.name);
|
|
49
|
+
try {
|
|
50
|
+
for (const schema of [tool.inputSchema, tool.outputSchema]) {
|
|
51
|
+
safeSchema(schema);
|
|
52
|
+
ajv.compile(schema);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return fail('CTL_EXTERNAL_CATALOG', 'Invalid external tool schema.', 5);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
/** A separate entry, never an ordinary ctl flow with relabeled provenance. */
|
|
62
|
+
export class ExternalClient {
|
|
63
|
+
store;
|
|
64
|
+
host;
|
|
65
|
+
request;
|
|
66
|
+
deadline = performance.now() + 30000;
|
|
67
|
+
constructor(store, host, request) {
|
|
68
|
+
this.store = store;
|
|
69
|
+
this.host = host;
|
|
70
|
+
this.request = request ?? createRequest(host.origin, () => Math.max(0, this.deadline - performance.now()), undefined, { ...store.trust(host), expectedFingerprint: host.fingerprint });
|
|
71
|
+
}
|
|
72
|
+
async execute(protocol, input) {
|
|
73
|
+
input = parseExternalRequest(JSON.stringify(input), protocol);
|
|
74
|
+
// Ambient credentials and URL overrides are for ordinary automation only.
|
|
75
|
+
if (this.host.ephemeral || canonicalOrigin(this.host.origin, !!this.host.tunnel) !== this.host.origin ||
|
|
76
|
+
this.host.origin.startsWith('https:') && !/^[a-f0-9]{64}$/.test(this.host.fingerprint ?? ''))
|
|
77
|
+
fail('CTL_EXTERNAL_HOST', 'External callers require a registered, pinned host.');
|
|
78
|
+
const snapshot = JSON.stringify(this.host);
|
|
79
|
+
const session = await this.store.session(this.host, { storedOnly: true });
|
|
80
|
+
const assertCurrent = async () => {
|
|
81
|
+
if (JSON.stringify(this.store.get(this.host.name)) !== snapshot)
|
|
82
|
+
fail('CTL_EXTERNAL_PINS_CHANGED', 'Host or App approval changed.');
|
|
83
|
+
const current = await this.store.session(this.host, { storedOnly: true });
|
|
84
|
+
if (current.cookie !== session.cookie || current.token !== session.token)
|
|
85
|
+
fail('CTL_NOT_AUTHENTICATED', 'Stored login changed.', 2);
|
|
86
|
+
};
|
|
87
|
+
await assertCurrent();
|
|
88
|
+
const packages = await new CtlClient(this.store, this.host, this.request).inventory(session);
|
|
89
|
+
// Cached descriptors select an already approved credential recipient only.
|
|
90
|
+
// Neither cached schemas nor a new package may establish external authority.
|
|
91
|
+
const candidates = packages.filter(pkg => {
|
|
92
|
+
const approval = this.host.approved[pkg.id], cached = this.store.cache(this.host, pkg.id, pkg.version);
|
|
93
|
+
return approval?.version === pkg.version && approval.hash && cached?.hash === approval.hash &&
|
|
94
|
+
Object.hasOwn(cached.description?.payload?.external ?? {}, protocol);
|
|
95
|
+
});
|
|
96
|
+
if (candidates.length !== 1)
|
|
97
|
+
fail('CTL_EXTERNAL_PROTOCOL', 'Protocol must match exactly one pinned App.');
|
|
98
|
+
const pkg = candidates[0], approval = this.host.approved[pkg.id];
|
|
99
|
+
await assertCurrent();
|
|
100
|
+
const path = `/webman/3rdparty/${pkg.id}/cgi/api.cgi`;
|
|
101
|
+
const described = await this.request(path, { method: 'synopkgland.describe', params: {} }, session);
|
|
102
|
+
if (described?.success !== true)
|
|
103
|
+
fail('CTL_EXTERNAL_DENIED', 'App description refused.');
|
|
104
|
+
const app = validateDescriptor(described.data, pkg.id, pkg.version);
|
|
105
|
+
if (app.sha256 !== approval.hash)
|
|
106
|
+
fail('CTL_DESCRIPTION_CHANGED', 'Pinned App description changed.');
|
|
107
|
+
const entry = app.payload.external?.[protocol];
|
|
108
|
+
if (!entry)
|
|
109
|
+
fail('CTL_EXTERNAL_PROTOCOL', 'App does not declare this protocol.');
|
|
110
|
+
if (app.payload.transport.auth !== 'dsm-session')
|
|
111
|
+
fail('CTL_EXTERNAL_TRANSPORT', 'External callers require the DSM session transport.');
|
|
112
|
+
const pins = { package: { id: pkg.id, version: pkg.version }, descriptionSha256: app.sha256 };
|
|
113
|
+
const output = new SensitiveOutput({ carriers: [session.token, session.cookie, ...session.cookie.split(';').map(part => part.slice(part.indexOf('=') + 1).trim())] }, { type: 'object', properties: { carriers: { writeOnly: true } } });
|
|
114
|
+
const invoke = async (request) => {
|
|
115
|
+
await assertCurrent();
|
|
116
|
+
// The App must verify these pins and revision in its dispatch boundary,
|
|
117
|
+
// freshly authenticate an administrator and derive authority server-side.
|
|
118
|
+
const reply = await this.request(path, { method: entry.method, params: { ...request, ...pins } }, session);
|
|
119
|
+
if (reply?.success !== true)
|
|
120
|
+
fail('CTL_EXTERNAL_DENIED', 'Trusted external entry refused.');
|
|
121
|
+
const proof = reply.data?.verified;
|
|
122
|
+
if (!object(proof) || proof.protocol !== protocol || proof.package?.id !== pkg.id || proof.package?.version !== pkg.version ||
|
|
123
|
+
proof.descriptionSha256 !== app.sha256 || proof.user !== session.username || proof.administrator !== true ||
|
|
124
|
+
!revision(proof.revision) || request.action === 'call' && proof.revision !== request.revision ||
|
|
125
|
+
request.action === 'describe' && proof.revision !== reply.data?.data?.revision)
|
|
126
|
+
fail('CTL_EXTERNAL_VERIFICATION', 'External verification did not match the pinned request.', 5);
|
|
127
|
+
return reply.data.data;
|
|
128
|
+
};
|
|
129
|
+
const catalog = validateCatalog(await invoke({ protocol, action: 'describe' }));
|
|
130
|
+
if (input.action === 'describe')
|
|
131
|
+
return output.clean(catalog);
|
|
132
|
+
if (catalog.revision !== input.revision)
|
|
133
|
+
fail('CTL_EXTERNAL_REVISION', 'External tool revision changed.');
|
|
134
|
+
const tool = catalog.tools.find(tool => tool._meta.method === input.method);
|
|
135
|
+
if (!tool)
|
|
136
|
+
fail('CTL_EXTERNAL_METHOD', 'Method is not an external tool.');
|
|
137
|
+
if (!ajv.validate(tool.inputSchema, input.arguments))
|
|
138
|
+
fail('CTL_EXTERNAL_INPUT', 'Arguments do not match the current tool schema.', 3);
|
|
139
|
+
const sensitive = new SensitiveOutput(input.arguments, tool.inputSchema);
|
|
140
|
+
// Exactly one call attempt. Errors, lost replies and invalid results never replay it.
|
|
141
|
+
const result = await invoke(input);
|
|
142
|
+
if (!ajv.validate(tool.outputSchema, result))
|
|
143
|
+
fail('CTL_EXTERNAL_RESULT', 'External result does not match its schema.', 5);
|
|
144
|
+
return output.clean(sensitive.clean(result, tool.outputSchema));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export async function runExternal(argv, io = {}) {
|
|
148
|
+
const out = io.out ?? console.log;
|
|
149
|
+
try {
|
|
150
|
+
if (argv.length === 1 && argv[0] === '--help') {
|
|
151
|
+
out(JSON.stringify({ ok: true, data: { usage: USAGE } }));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const values = new Map();
|
|
155
|
+
for (let i = 0; i < argv.length; i++) {
|
|
156
|
+
const flag = argv[i], key = flag.split('=')[0];
|
|
157
|
+
if (!['--protocol', '--host', '--input', '--json'].includes(key) || values.has(key))
|
|
158
|
+
fail('CTL_EXTERNAL_FLAGS', 'Invalid external options.', 3);
|
|
159
|
+
if (key === '--json') {
|
|
160
|
+
if (flag !== key)
|
|
161
|
+
fail('CTL_EXTERNAL_FLAGS', 'Invalid JSON option.', 3);
|
|
162
|
+
values.set(key, 'true');
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const value = flag.includes('=') ? flag.slice(key.length + 1) : argv[++i];
|
|
166
|
+
if (!value || value.startsWith('--'))
|
|
167
|
+
fail('CTL_EXTERNAL_FLAGS', 'Missing external option value.', 3);
|
|
168
|
+
values.set(key, value);
|
|
169
|
+
}
|
|
170
|
+
const protocol = values.get('--protocol'), hostName = values.get('--host');
|
|
171
|
+
if (values.size !== 4 || !protocol || !PROTOCOL.test(protocol) || !hostName || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(hostName) ||
|
|
172
|
+
values.get('--input') !== '-' || nasOptions().url || nasOptions().ca || nasOptions().strictTls)
|
|
173
|
+
fail('CTL_EXTERNAL_FLAGS', 'Use the fixed external command contract.', 3);
|
|
174
|
+
let raw;
|
|
175
|
+
try {
|
|
176
|
+
raw = readBounded(0);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return fail('CTL_EXTERNAL_INPUT', 'Cannot read bounded UTF-8 stdin.', 3);
|
|
180
|
+
}
|
|
181
|
+
const input = parseExternalRequest(raw, protocol);
|
|
182
|
+
const store = io.store ?? new CtlStore(), host = store.get(hostName);
|
|
183
|
+
const data = await new ExternalClient(store, host, io.requestFor?.(host)).execute(protocol, input);
|
|
184
|
+
const line = JSON.stringify({ ok: true, data });
|
|
185
|
+
if (Buffer.byteLength(line + '\n') > LIMIT)
|
|
186
|
+
fail('CTL_EXTERNAL_OUTPUT', 'External output exceeds 1 MiB.', 5);
|
|
187
|
+
out(line);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const e = ctlError(error);
|
|
191
|
+
// Never echo upstream messages, input, session carriers or exception text.
|
|
192
|
+
out(JSON.stringify({ ok: false, error: { code: e.code, message: 'External request refused.', hint: HINT } }));
|
|
193
|
+
process.exitCode = e.exitCode;
|
|
194
|
+
}
|
|
195
|
+
}
|
package/dist/error-registry.js
CHANGED
|
@@ -553,6 +553,11 @@ export const ERROR_REGISTRY = {
|
|
|
553
553
|
description: "ui.window does not give a valid DSM window size.",
|
|
554
554
|
suggestion: "Give width and height as whole numbers from 320 to 3840 and 240 to 2160, keep minWidth and minHeight (700 and 420 by default) no larger than them, and write only the fields you change.",
|
|
555
555
|
},
|
|
556
|
+
STANDALONE_UI_BANNER_INVALID: {
|
|
557
|
+
code: "STANDALONE_UI_BANNER_INVALID",
|
|
558
|
+
description: "ui.banner is neither shown nor hidden.",
|
|
559
|
+
suggestion: "Set ui.banner to hidden to drop DSM's banner from the App's standalone page, or omit it to keep the banner.",
|
|
560
|
+
},
|
|
556
561
|
STANDALONE_UI_BUILD_FAILED: {
|
|
557
562
|
code: "STANDALONE_UI_BUILD_FAILED",
|
|
558
563
|
description: "The UI build command failed.",
|
package/dist/nas/store.d.ts
CHANGED
|
@@ -44,7 +44,9 @@ export declare class CtlStore {
|
|
|
44
44
|
save(host: Host): void;
|
|
45
45
|
add(host: Host, session: DsmSession): Promise<void>;
|
|
46
46
|
trust(host: Host): HostTls;
|
|
47
|
-
session(host: Host
|
|
47
|
+
session(host: Host, options?: {
|
|
48
|
+
storedOnly?: boolean;
|
|
49
|
+
}): Promise<DsmSession>;
|
|
48
50
|
cacheRoot(host: Host): string;
|
|
49
51
|
cacheFile(host: Host, pkg: string, version: string): string;
|
|
50
52
|
cache(host: Host, pkg: string, version: string): any | null;
|
package/dist/nas/store.js
CHANGED
|
@@ -94,9 +94,9 @@ export class CtlStore {
|
|
|
94
94
|
} },
|
|
95
95
|
record: (fingerprint) => { host.fingerprint = fingerprint; this.save(host); }, warn: override.warn };
|
|
96
96
|
}
|
|
97
|
-
async session(host) {
|
|
97
|
+
async session(host, options = {}) {
|
|
98
98
|
let session;
|
|
99
|
-
if (process.env.DSM_USERNAME && process.env.DSM_PASSWORD) {
|
|
99
|
+
if (!options.storedOnly && process.env.DSM_USERNAME && process.env.DSM_PASSWORD) {
|
|
100
100
|
if (process.env.DSM_USERNAME !== host.username)
|
|
101
101
|
throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Automation account differs from the registered host.', 'Use the registered account or a separately named host.', 2);
|
|
102
102
|
session = await loginDsm(host.origin, host.username, process.env.DSM_PASSWORD, { remember: false, otp: async () => process.env.DSM_OTP ?? '', request: createRequest(host.origin, 30000, undefined, this.trust(host)) });
|
|
@@ -34,6 +34,7 @@ export async function buildApiDescription(m, bridgeMethods, run) {
|
|
|
34
34
|
throw new StandaloneBuildError('CTL_DESCRIBE_FAILED', 'Invalid registry snapshot.');
|
|
35
35
|
const registry = new Map(methods.map(item => [item.method, item.permission]));
|
|
36
36
|
const rawHTTP = new Set(methods.filter(item => item.kind === 'http').map(item => item.method));
|
|
37
|
+
const nonRPC = new Set(methods.filter(item => item.kind && item.kind !== 'json').map(item => item.method));
|
|
37
38
|
// Identity transport routes are outside the business registry but have immutable SDK ownership.
|
|
38
39
|
if (m.capabilities.identity) {
|
|
39
40
|
registry.set('identity.session', 'public');
|
|
@@ -41,6 +42,16 @@ export async function buildApiDescription(m, bridgeMethods, run) {
|
|
|
41
42
|
}
|
|
42
43
|
const bindings = {};
|
|
43
44
|
const commands = {};
|
|
45
|
+
if (m.ctl.external) {
|
|
46
|
+
if (m.capabilities.identity)
|
|
47
|
+
throw new StandaloneBuildError('CTL_EXTERNAL_TRANSPORT', 'External entries require the DSM session transport.');
|
|
48
|
+
for (const entry of Object.values(m.ctl.external)) {
|
|
49
|
+
if (!entry || typeof entry.method !== 'string' || !registry.has(entry.method) || nonRPC.has(entry.method) || m.entry !== 'admin-only' && registry.get(entry.method) !== 'administrators')
|
|
50
|
+
throw new StandaloneBuildError('CTL_EXTERNAL_ENTRY', 'External entries require registered administrator RPC methods.');
|
|
51
|
+
registry.set(entry.method, 'administrators');
|
|
52
|
+
bindings[entry.method] = { permission: 'administrators', effect: 'destructive' };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
44
55
|
const weights = { read: 0, write: 1, destructive: 2 };
|
|
45
56
|
for (const [id, declared] of Object.entries(m.ctl.commands)) {
|
|
46
57
|
const command = structuredClone(declared);
|
|
@@ -66,7 +77,7 @@ export async function buildApiDescription(m, bridgeMethods, run) {
|
|
|
66
77
|
for (const method of ['identity.session', 'identity.logout'])
|
|
67
78
|
bindings[method] = { permission: 'public', effect: 'write' };
|
|
68
79
|
}
|
|
69
|
-
const description = makeDescriptor({ package: { id: m.package, version: m.spkVersion, displayName: m.displayname }, aliases: m.ctl.aliases ?? [m.package.toLowerCase()], provenance: { developedBy: m.ctl.developedBy ?? 'unknown', generator: 'synopkgland-cli' }, entry: m.entry ?? 'policy', transport: { path: `/webman/3rdparty/${m.package}/cgi/api.cgi`, auth: m.capabilities.identity ? 'dsm-app-session' : 'dsm-session' }, commands, bindings });
|
|
80
|
+
const description = makeDescriptor({ package: { id: m.package, version: m.spkVersion, displayName: m.displayname }, aliases: m.ctl.aliases ?? [m.package.toLowerCase()], provenance: { developedBy: m.ctl.developedBy ?? 'unknown', generator: 'synopkgland-cli' }, entry: m.entry ?? 'policy', transport: { path: `/webman/3rdparty/${m.package}/cgi/api.cgi`, auth: m.capabilities.identity ? 'dsm-app-session' : 'dsm-session' }, commands, bindings, ...(m.ctl.external ? { external: m.ctl.external } : {}) });
|
|
70
81
|
try {
|
|
71
82
|
return { description: validateDescriptor(description, m.package, m.spkVersion), registry: Object.fromEntries(registry) };
|
|
72
83
|
}
|
|
@@ -225,6 +225,7 @@ export function renderWindowScript(manifest, assetVersion, icons = packageIcons(
|
|
|
225
225
|
const p = JSON.stringify(pkg);
|
|
226
226
|
const root = manifest.executor ? renderRootParts(ns, manifest) : undefined;
|
|
227
227
|
const size = manifest.ui.window ?? DEFAULT_WINDOW;
|
|
228
|
+
const hideBanner = manifest.ui.banner === "hidden";
|
|
228
229
|
const requestTimeout = requestTimeoutSeconds(manifest.backend) * 1000;
|
|
229
230
|
const clientMargin = appSdkTimeouts(manifest.backend).browserMarginMs;
|
|
230
231
|
const sessionRuntime = appSdkSessionRuntime(manifest.backend);
|
|
@@ -473,7 +474,15 @@ ${ns}.prepareFullPage = function (title) {
|
|
|
473
474
|
favicon.href = ${ns}.appIcon;
|
|
474
475
|
};
|
|
475
476
|
|
|
476
|
-
${
|
|
477
|
+
${hideBanner ? `// ui.banner: hidden. DSM's own "Hide banner" class on a standalone page; on the
|
|
478
|
+
// desktop it would hide the window's title bar, so the window keeps it there.
|
|
479
|
+
${ns}.hideBanner = function (config) {
|
|
480
|
+
if (!${ns}.fullPage()) return config;
|
|
481
|
+
config = Ext.apply({}, config);
|
|
482
|
+
config.cls = (config.cls ? config.cls + " " : "") + "sds-standalone-main-window--no-banner";
|
|
483
|
+
return config;
|
|
484
|
+
};
|
|
485
|
+
` : ""}${windowResumesOIDC(manifest) ? `${ns}.resumeOIDC = ${oidcResumeRuntime(pkg, Object.values(manifest.identityGateway.listen).filter((p) => typeof p === "number"))};` : ""}
|
|
477
486
|
|
|
478
487
|
// DSM reopens a window at the size it was last left at, even one saved on a
|
|
479
488
|
// larger screen, and only moves a window that does not fit: keep it inside.
|
|
@@ -495,8 +504,10 @@ Ext.define(${JSON.stringify(`${ns}.MainWindow`)}, {
|
|
|
495
504
|
this.callParent([${ns}.fitDesktop(Ext.apply({
|
|
496
505
|
width: ${size.width}, height: ${size.height}, minWidth: ${ns}.fullPage() ? ${FULL_PAGE_MIN_WIDTH} : ${size.minWidth}, minHeight: ${size.minHeight}, layout: "fit",
|
|
497
506
|
${root ? root.html : `html: '<div class="dsmapp-root" style="height:100%"></div>'`}
|
|
498
|
-
}, config))]);
|
|
499
|
-
}
|
|
507
|
+
}, ${hideBanner ? `${ns}.hideBanner(config)` : "config"}))]);
|
|
508
|
+
},${hideBanner ? `
|
|
509
|
+
// DSM draws the banner unless its global "Hide banner" is on; skip it for this App alone.
|
|
510
|
+
genIndPortHeader: function () {},` : ""}
|
|
500
511
|
getRestoreSizePos: function (config) {
|
|
501
512
|
return ${ns}.fitDesktop(Ext.apply({ width: config.width, height: config.height }, this.callParent(arguments)));
|
|
502
513
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { HostAdapter, HostTitleResult, HostRequestResult, HostURLResult, HostEvent, HostSubscriptionResult, HostFileNavigationResult, HostUnsupportedResult } from "./host-types.js";
|
|
2
|
-
export declare const HOST_ADAPTER_RUNTIME = "function inspectHostPlatform(ctx) {\n ctx = ctx || {};\n var web = typeof window !== \"undefined\";\n var host = false;\n try {\n host = !!(web && ctx.appWindow && typeof SYNO !== \"undefined\" &&\n SYNO.SDS && typeof SYNO.SDS.AppWindow === \"function\" &&\n ctx.appWindow instanceof SYNO.SDS.AppWindow);\n } catch (error) {\n // An unsupported host constructor must not break a standalone shell.\n }\n function read(key) {\n if (!host || typeof _S !== \"function\") return null;\n try { return _S(key); } catch (error) { return null; }\n }\n function text(value) {\n return typeof value === \"string\" && /\\S/.test(value) ? value : null;\n }\n function versionPart(value) {\n if (typeof value === \"number\") {\n if (!isFinite(value) || value < 0 || Math.floor(value) !== value) return null;\n value = String(value);\n }\n return typeof value === \"string\" && value.length > 0 && !/\\D/.test(value) ? value : null;\n }\n var standalone = read(\"standalone\");\n var language = text(read(\"lang\"));\n var major = versionPart(read(\"majorversion\"));\n var minor = versionPart(read(\"minorversion\"));\n var fullVersion = text(read(\"fullversion\"));\n return Object.freeze({\n web: web,\n host: host ? \"dsm\" : web ? \"standalone-web\" : \"unavailable\",\n dsmStandalone: typeof standalone === \"boolean\" ? standalone : null,\n dsmLanguage: language,\n appLocale: text(ctx.locale),\n systemVersion: major !== null && minor !== null ? major + \".\" + minor : null,\n fullVersion: fullVersion,\n theme: null\n });\n}\n\nfunction requestHostFileNavigation(launch, path, isDirectory) {\n if (typeof path !== \"string\" || path.length > 4096 || path.charAt(0) !== \"/\" ||\n /[\\x00-\\x1f\\x7f\\\\]/.test(path)) return { status: \"invalid-input\" };\n var volume = /^\\/volume(?:USB|SATA)?[0-9]+(?=\\/|$)/.exec(path);\n if (volume) path = path.slice(volume[0].length);\n var parts = path.split(\"/\");\n if (parts.length < 2 || parts.slice(1).some(function (part) {\n return !part || part === \".\" || part === \"..\";\n })) return { status: \"invalid-input\" };\n var result = launch(\"SYNO.SDS.App.FileStation3.Instance\", isDirectory ? { opendir: path } : { openfile: path });\n if (result.status !== \"requested\") return result;\n return { status: \"requested\", action: isDirectory ? \"locate-folder\" : \"highlight-file\" };\n}\n\n\nvar REVEAL_RECOVERY_TIMEOUT_MS = 5000;\nvar REVEAL_RECOVERY_POLL_MS = 100;\nvar REVEAL_RECOVERY_QUIET_MS = 300;\nvar hostFileRevealOwners = null, hostFileRevealSequence = 0;\ntry { if (typeof WeakMap === \"function\") hostFileRevealOwners = new WeakMap(); } catch (error) {}\nfunction createHostFileRevealRecovery(path, canAct) {\n var active = true, accepted = false, win = null, dom = null, tuple = null;\n var poll = null, deadline = null, removers = [], reached = false, pending = 0;\n var previous = null, quietSince = null, expires = 0, order = ++hostFileRevealSequence;\n var parent = path.slice(0, path.lastIndexOf(\"/\"));\n function cancel() {\n if (!active) return;\n active = false;\n try { if (poll !== null) clearTimeout(poll); } catch (error) {}\n try { if (deadline !== null) clearTimeout(deadline); } catch (error) {}\n poll = deadline = null;\n removers.splice(0).forEach(function (remove) { try { remove(); } catch (error) {} });\n try {\n if (win && hostFileRevealOwners.get(win).order === order) {\n // Preserve ordering for a late older callback, without retaining its closures.\n hostFileRevealOwners.set(win, { order: order, cancel: null });\n }\n } catch (error) {}\n win = dom = tuple = previous = null;\n }\n function requireMethods(target, names) {\n if (!target || names.some(function (name) { return typeof target[name] !== \"function\"; })) throw new Error(\"unsupported receiver\");\n }\n function guard(fn) {\n return function () {\n if (!active) return;\n try { fn.apply(null, arguments); } catch (error) { cancel(); }\n };\n }\n function listen(target, event, fn) {\n requireMethods(target, [\"on\", \"un\"]);\n var handler = guard(fn), remove = target.un;\n removers.push(function () { remove.call(target, event, handler, target); });\n target.on(event, handler, target);\n }\n function resetQuiet() { previous = null; quietSince = null; }\n function inspect() {\n requireMethods(win, [\"getPanelInstance\", \"getEl\", \"on\", \"un\"]);\n if (win.isDestroyed || win.destroyed || !canAct()) { cancel(); return null; }\n var element = win.getEl(), nextDom = element && element.dom;\n if (!nextDom) { if (dom) cancel(); return null; }\n requireMethods(nextDom, [\"addEventListener\", \"removeEventListener\"]);\n if (dom && nextDom !== dom) { cancel(); return null; }\n if (!dom) {\n dom = nextDom;\n [\"pointerdown\", \"mousedown\", \"touchstart\", \"keydown\", \"wheel\", \"click\", \"input\"].forEach(function (event) {\n var remove = dom.removeEventListener, handler = guard(cancel), target = dom;\n removers.push(function () { remove.call(target, event, handler, true); });\n dom.addEventListener(event, handler, true);\n });\n }\n var panel = win.getPanelInstance();\n if (!panel) { if (tuple) cancel(); return null; }\n requireMethods(panel, [\"getCurrentDir\", \"on\", \"un\"]);\n var view = panel.activeView, store = panel.activeDS;\n requireMethods(view, [\"getStore\", \"getSelectionModel\", \"scrollToItem\"]);\n requireMethods(store, [\"on\", \"un\", \"indexOfId\", \"getAt\", \"getCount\"]);\n var model = view.getSelectionModel();\n requireMethods(model, [\"on\", \"un\", \"getSelections\", \"selectRow\"]);\n if (view.getStore() !== store || panel.isDestroyed || store.isDestroyed || model.isDestroyed) { cancel(); return null; }\n if (tuple && (tuple.panel !== panel || tuple.view !== view || tuple.store !== store || tuple.model !== model)) { cancel(); return null; }\n if (!tuple) {\n tuple = { panel: panel, view: view, store: store, model: model };\n listen(panel, \"destroy\", cancel);\n listen(panel, \"onNodeClick\", function (node) {\n if (!node || !node.attributes || (reached && node.attributes.path !== parent)) cancel();\n });\n listen(store, \"beforeload\", function () {\n pending++; resetQuiet(); inspect();\n });\n listen(store, \"load\", function () {\n if (pending > 0) pending--;\n resetQuiet(); check();\n });\n [\"exception\", \"loadexception\", \"destroy\"].forEach(function (event) { listen(store, event, cancel); });\n listen(model, \"selectionchange\", function () { resetQuiet(); check(); });\n }\n var dir = panel.getCurrentDir(), params = store.baseParams;\n // New File Station panels expose their APIs before the first directory is set.\n // Keep the bound receiver guards and original deadline while startup finishes.\n if (!reached && dir === undefined && (!params || params.folder_path === undefined)) return null;\n if (!params || typeof params.folder_path !== \"string\") throw new Error(\"unsupported store path\");\n // Native reconfigure/selection events can precede the store's path assignment.\n if (dir === parent && params.folder_path === parent) reached = true;\n if (reached && (dir !== parent || params.folder_path !== parent)) { cancel(); return null; }\n var selections = model.getSelections();\n if (!Array.isArray(selections)) throw new Error(\"unsupported selection\");\n if (selections.some(function (record) { return !record || record.id !== path; })) { cancel(); return null; }\n var index = store.indexOfId(path), count = store.getCount();\n if (!Number.isInteger(index) || index < -1 || !Number.isInteger(count) || count < 0) throw new Error(\"unsupported store index\");\n var record = index >= 0 ? store.getAt(index) : null;\n if (index >= 0 && (!record || record.id !== path || index >= count)) throw new Error(\"inconsistent store\");\n return { dir: dir, folder: params.folder_path, count: count, index: index, record: record, selected: selections.length > 0 };\n }\n function check() {\n if (!active) return;\n if (Date.now() >= expires || !canAct()) { cancel(); return; }\n if (!win) return;\n var state = inspect();\n if (!active || !state) return;\n if (pending || state.dir !== parent || state.folder !== parent || state.index < 0) { resetQuiet(); return; }\n if (!previous || Object.keys(state).some(function (key) { return state[key] !== previous[key]; })) {\n previous = state; quietSince = Date.now();\n }\n if (Date.now() - quietSince < REVEAL_RECOVERY_QUIET_MS) return;\n var target = tuple, receiverWindow = win;\n cancel(); // Release every observer before the one permitted selection mutation.\n if (state.selected || !canAct()) return;\n target.model.selectRow(state.index);\n if (canAct() && !receiverWindow.isDestroyed && receiverWindow.getPanelInstance() === target.panel &&\n target.panel.activeView === target.view && target.panel.activeDS === target.store &&\n target.view.getSelectionModel() === target.model) target.view.scrollToItem(state.index);\n }\n function tick() {\n poll = null;\n try {\n check();\n if (active) poll = setTimeout(tick, REVEAL_RECOVERY_POLL_MS);\n } catch (error) { cancel(); }\n }\n function receive(instance) {\n if (!active || accepted) return;\n accepted = true;\n try {\n if (Date.now() >= expires || !canAct()) { cancel(); return; }\n win = instance && instance.window;\n requireMethods(win, [\"getPanelInstance\", \"getEl\", \"on\", \"un\"]);\n var owner = hostFileRevealOwners.get(win);\n if (owner && owner.order > order) { cancel(); return; }\n if (owner && owner.cancel) owner.cancel();\n hostFileRevealOwners.set(win, { order: order, cancel: cancel });\n [\"beforeclose\", \"close\", \"beforedestroy\", \"destroy\", \"hide\"].forEach(function (event) { listen(win, event, cancel); });\n check();\n } catch (error) { cancel(); }\n }\n try {\n if (!parent || !hostFileRevealOwners || typeof setTimeout !== \"function\" || typeof clearTimeout !== \"function\" || typeof Date.now !== \"function\") {\n cancel();\n } else {\n expires = Date.now() + REVEAL_RECOVERY_TIMEOUT_MS;\n deadline = setTimeout(cancel, REVEAL_RECOVERY_TIMEOUT_MS);\n poll = setTimeout(tick, REVEAL_RECOVERY_POLL_MS);\n }\n } catch (error) { cancel(); }\n return { receive: receive, cancel: cancel };\n}\n\nfunction createHostAdapter(ctx) {\n ctx = ctx || {};\n var disposed = false, subscriptions = [], revealRecovery = null;\n function launchApp(appId, config, receiverCallback) {\n if (disposed) return { status: \"unavailable\" };\n if (typeof appId !== \"string\" || !appId || /\\s/.test(appId)) return { status: \"invalid-input\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var sds = SYNO.SDS, map = sds.Config && sds.Config.FnMap, notifier = sds.StatusNotifier;\n if (typeof sds.AppLaunch !== \"function\" || !map ||\n !Object.prototype.hasOwnProperty.call(map, appId) || !map[appId] || !notifier ||\n typeof notifier.isAppEnabled !== \"function\" || !notifier.isAppEnabled(appId) ||\n typeof notifier.isAppLaunchable !== \"function\" || !notifier.isAppLaunchable(appId)) {\n return { status: \"unsupported\" };\n }\n if (receiverCallback) sds.AppLaunch(appId, config, undefined, receiverCallback);\n else sds.AppLaunch(appId, config);\n return { status: \"requested\" };\n } catch (error) { return { status: \"failed\" }; }\n }\n function navigateFileStation(path, isDirectory) {\n if (disposed) return { status: \"unavailable\" };\n if (revealRecovery) revealRecovery.cancel();\n revealRecovery = null;\n function launchFile(appId, config) {\n if (isDirectory) return launchApp(appId, config);\n var recovery = createHostFileRevealRecovery(config.openfile, function () { return !disposed && revealRecovery === recovery; });\n revealRecovery = recovery;\n var result = launchApp(appId, config, recovery.receive);\n if (result.status !== \"requested\") recovery.cancel();\n return result;\n }\n return requestHostFileNavigation(launchFile, path, isDirectory);\n }\n function unsupportedOperation() { return { status: disposed ? \"unavailable\" : \"unsupported\" }; }\n return Object.freeze({\n platform: function () {\n if (!disposed) return inspectHostPlatform(ctx);\n return Object.freeze({\n web: typeof window !== \"undefined\", host: \"unavailable\",\n dsmStandalone: null, dsmLanguage: null, appLocale: null,\n systemVersion: null, fullVersion: null, theme: null\n });\n },\n setTitle: function (title) {\n if (disposed) return { status: \"unavailable\" };\n if (typeof title !== \"string\") return { status: \"invalid-input\" };\n try {\n var platform = inspectHostPlatform(ctx);\n if (platform.host === \"dsm\") {\n if (typeof ctx.appWindow.setTitle !== \"function\") return { status: \"unsupported\" };\n ctx.appWindow.setTitle(title, undefined, true);\n return { status: \"ok\", target: \"dsm-window\" };\n }\n if (!platform.web) return { status: \"unavailable\" };\n if (typeof document === \"undefined\") return { status: \"unsupported\" };\n document.title = title;\n return { status: \"ok\", target: \"document\" };\n } catch (error) {\n return { status: \"failed\" };\n }\n },\n activateWindow: function () {\n if (disposed) return { status: \"unavailable\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var win = ctx.appWindow;\n var activate = typeof win.toFront === \"function\" ? win.toFront : win.activate;\n if (typeof activate !== \"function\") return { status: \"unsupported\" };\n // DSM minimizes by hiding; show preserves maximized geometry, unlike restore.\n if (win.hidden === true || win.minimized === true ||\n (typeof win.isVisible === \"function\" && win.isVisible() === false)) {\n if (typeof win.show !== \"function\") return { status: \"unsupported\" };\n win.show();\n }\n activate.call(win);\n return { status: \"requested\" };\n } catch (error) { return { status: \"failed\" }; }\n },\n requestClose: function () {\n if (disposed) return { status: \"unavailable\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var win = ctx.appWindow;\n // DSM returns a short-circuit value; only truthy means standalone main.\n if (typeof win.close !== \"function\" || typeof win.isStandaloneMainWindow !== \"function\" ||\n win.isStandaloneMainWindow()) return { status: \"unsupported\" };\n if (win.canClose === false) return { status: \"blocked\" };\n win.close();\n return { status: \"requested\" };\n } catch (error) { return { status: \"failed\" }; }\n },\n openApp: function (appId) { return launchApp(appId, {}); },\n openURL: function (raw) {\n if (disposed || typeof window === \"undefined\") return { status: \"unavailable\" };\n if (typeof raw !== \"string\" || !raw || /[\\s\\x00-\\x1f\\x7f]/.test(raw)) return { status: \"invalid-input\" };\n if (typeof URL !== \"function\") return { status: \"unsupported\" };\n var url;\n try { url = new URL(raw); } catch (error) { return { status: \"invalid-input\" }; }\n if ((url.protocol !== \"http:\" && url.protocol !== \"https:\") || url.username || url.password) return { status: \"invalid-input\" };\n if (typeof window.open !== \"function\") return { status: \"unsupported\" };\n try {\n var opened = window.open(url.href, \"_blank\", \"noopener,noreferrer\");\n return { status: \"requested\", windowHandleAvailable: !!opened };\n } catch (error) { return { status: \"failed\" }; }\n },\n openFileManager: function (path) { return navigateFileStation(path, true); },\n revealFile: function (path) { return navigateFileStation(path, false); },\n openFile: unsupportedOperation,\n showFileDetails: unsupportedOperation,\n openAppSetting: unsupportedOperation,\n subscribe: function (event, listener) {\n if (disposed) return { status: \"unavailable\" };\n if (typeof listener !== \"function\") return { status: \"invalid-input\" };\n if (event !== \"window-resize\") return { status: \"unsupported\" };\n var win, callback = listener, active = true;\n function handler() { if (!disposed && callback) callback(); }\n function unsubscribe() {\n if (!active) return;\n active = false;\n callback = null;\n var target = win;\n win = null;\n var index = subscriptions.indexOf(unsubscribe);\n if (index !== -1) subscriptions.splice(index, 1);\n try { if (target) target.un(\"resize\", handler, target); } catch (error) {}\n }\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n win = ctx.appWindow;\n if (typeof win.on !== \"function\" || typeof win.un !== \"function\") return { status: \"unsupported\" };\n subscriptions.push(unsubscribe);\n win.on(\"resize\", handler, win);\n if (disposed) return { status: \"unavailable\" };\n return Object.freeze({ status: \"subscribed\", dispose: unsubscribe });\n } catch (error) {\n unsubscribe();\n return { status: \"failed\" };\n }\n },\n dispose: function () {\n if (disposed) return;\n disposed = true;\n if (revealRecovery) revealRecovery.cancel();\n revealRecovery = null;\n subscriptions.slice().forEach(function (unsubscribe) { unsubscribe(); });\n ctx = null;\n }\n });\n}";
|
|
2
|
+
export declare const HOST_ADAPTER_RUNTIME = "function inspectHostPlatform(ctx) {\n ctx = ctx || {};\n var web = typeof window !== \"undefined\";\n var host = false;\n try {\n host = !!(web && ctx.appWindow && typeof SYNO !== \"undefined\" &&\n SYNO.SDS && typeof SYNO.SDS.AppWindow === \"function\" &&\n ctx.appWindow instanceof SYNO.SDS.AppWindow);\n } catch (error) {\n // An unsupported host constructor must not break a standalone shell.\n }\n function read(key) {\n if (!host || typeof _S !== \"function\") return null;\n try { return _S(key); } catch (error) { return null; }\n }\n function text(value) {\n return typeof value === \"string\" && /\\S/.test(value) ? value : null;\n }\n function versionPart(value) {\n if (typeof value === \"number\") {\n if (!isFinite(value) || value < 0 || Math.floor(value) !== value) return null;\n value = String(value);\n }\n return typeof value === \"string\" && value.length > 0 && !/\\D/.test(value) ? value : null;\n }\n var standalone = read(\"standalone\");\n var language = text(read(\"lang\"));\n var major = versionPart(read(\"majorversion\"));\n var minor = versionPart(read(\"minorversion\"));\n var fullVersion = text(read(\"fullversion\"));\n return Object.freeze({\n web: web,\n host: host ? \"dsm\" : web ? \"standalone-web\" : \"unavailable\",\n dsmStandalone: typeof standalone === \"boolean\" ? standalone : null,\n dsmLanguage: language,\n appLocale: text(ctx.locale),\n systemVersion: major !== null && minor !== null ? major + \".\" + minor : null,\n fullVersion: fullVersion,\n theme: null\n });\n}\n\nfunction requestHostFileNavigation(launch, path, isDirectory) {\n if (typeof path !== \"string\" || path.length > 4096 || path.charAt(0) !== \"/\" ||\n /[\\x00-\\x1f\\x7f\\\\]/.test(path)) return { status: \"invalid-input\" };\n var volume = /^\\/volume(?:USB|SATA)?[0-9]+(?=\\/|$)/.exec(path);\n if (volume) path = path.slice(volume[0].length);\n var parts = path.split(\"/\");\n if (parts.length < 2 || parts.slice(1).some(function (part) {\n return !part || part === \".\" || part === \"..\";\n })) return { status: \"invalid-input\" };\n var result = launch(\"SYNO.SDS.App.FileStation3.Instance\", isDirectory ? { opendir: path } : { openfile: path });\n if (result.status !== \"requested\") return result;\n return { status: \"requested\", action: isDirectory ? \"locate-folder\" : \"highlight-file\" };\n}\n\n\nvar REVEAL_RECOVERY_TIMEOUT_MS = 5000;\nvar REVEAL_RECOVERY_POLL_MS = 100;\nvar REVEAL_RECOVERY_QUIET_MS = 300;\nvar hostFileRevealOwners = null, hostFileRevealSequence = 0;\ntry { if (typeof WeakMap === \"function\") hostFileRevealOwners = new WeakMap(); } catch (error) {}\nfunction createHostFileRevealRecovery(path, canAct) {\n var active = true, accepted = false, win = null, dom = null, tuple = null;\n var poll = null, deadline = null, removers = [], reached = false, pending = 0;\n var previous = null, quietSince = null, expires = 0, order = ++hostFileRevealSequence;\n var parent = path.slice(0, path.lastIndexOf(\"/\"));\n function cancel() {\n if (!active) return;\n active = false;\n try { if (poll !== null) clearTimeout(poll); } catch (error) {}\n try { if (deadline !== null) clearTimeout(deadline); } catch (error) {}\n poll = deadline = null;\n removers.splice(0).forEach(function (remove) { try { remove(); } catch (error) {} });\n try {\n if (win && hostFileRevealOwners.get(win).order === order) {\n // Preserve ordering for a late older callback, without retaining its closures.\n hostFileRevealOwners.set(win, { order: order, cancel: null });\n }\n } catch (error) {}\n win = dom = tuple = previous = null;\n }\n function requireMethods(target, names) {\n if (!target || names.some(function (name) { return typeof target[name] !== \"function\"; })) throw new Error(\"unsupported receiver\");\n }\n function guard(fn) {\n return function () {\n if (!active) return;\n try { fn.apply(null, arguments); } catch (error) { cancel(); }\n };\n }\n function listen(target, event, fn) {\n requireMethods(target, [\"on\", \"un\"]);\n var handler = guard(fn), remove = target.un;\n removers.push(function () { remove.call(target, event, handler, target); });\n target.on(event, handler, target);\n }\n function resetQuiet() { previous = null; quietSince = null; }\n function inspect() {\n requireMethods(win, [\"getPanelInstance\", \"getEl\", \"on\", \"un\"]);\n if (win.isDestroyed || win.destroyed || !canAct()) { cancel(); return null; }\n var element = win.getEl(), nextDom = element && element.dom;\n if (!nextDom) { if (dom) cancel(); return null; }\n requireMethods(nextDom, [\"addEventListener\", \"removeEventListener\"]);\n if (dom && nextDom !== dom) { cancel(); return null; }\n if (!dom) {\n dom = nextDom;\n [\"pointerdown\", \"mousedown\", \"touchstart\", \"keydown\", \"wheel\", \"click\", \"input\"].forEach(function (event) {\n var remove = dom.removeEventListener, handler = guard(cancel), target = dom;\n removers.push(function () { remove.call(target, event, handler, true); });\n dom.addEventListener(event, handler, true);\n });\n }\n var panel = win.getPanelInstance();\n if (!panel) { if (tuple) cancel(); return null; }\n requireMethods(panel, [\"getCurrentDir\", \"on\", \"un\"]);\n var view = panel.activeView, store = panel.activeDS;\n requireMethods(view, [\"getStore\", \"getSelectionModel\", \"scrollToItem\"]);\n requireMethods(store, [\"on\", \"un\", \"indexOfId\", \"getAt\", \"getCount\"]);\n var model = view.getSelectionModel();\n requireMethods(model, [\"on\", \"un\", \"getSelections\", \"selectRow\"]);\n if (view.getStore() !== store || panel.isDestroyed || store.isDestroyed || model.isDestroyed) { cancel(); return null; }\n if (tuple && (tuple.panel !== panel || tuple.view !== view || tuple.store !== store || tuple.model !== model)) { cancel(); return null; }\n if (!tuple) {\n tuple = { panel: panel, view: view, store: store, model: model };\n listen(panel, \"destroy\", cancel);\n listen(panel, \"onNodeClick\", function (node) {\n if (!node || !node.attributes || (reached && node.attributes.path !== parent)) cancel();\n });\n listen(store, \"beforeload\", function () {\n pending++; resetQuiet(); inspect();\n });\n listen(store, \"load\", function () {\n if (pending > 0) pending--;\n resetQuiet(); check();\n });\n [\"exception\", \"loadexception\", \"destroy\"].forEach(function (event) { listen(store, event, cancel); });\n listen(model, \"selectionchange\", function () { resetQuiet(); check(); });\n }\n var dir = panel.getCurrentDir(), params = store.baseParams;\n // New File Station panels expose their APIs before the first directory is set.\n // Keep the bound receiver guards and original deadline while startup finishes.\n if (!reached && dir === undefined && (!params || params.folder_path === undefined)) return null;\n if (!params || typeof params.folder_path !== \"string\") throw new Error(\"unsupported store path\");\n // Native reconfigure/selection events can precede the store's path assignment.\n if (dir === parent && params.folder_path === parent) reached = true;\n if (reached && (dir !== parent || params.folder_path !== parent)) { cancel(); return null; }\n var selections = model.getSelections();\n if (!Array.isArray(selections)) throw new Error(\"unsupported selection\");\n if (selections.some(function (record) { return !record || record.id !== path; })) { cancel(); return null; }\n var index = store.indexOfId(path), count = store.getCount();\n if (!Number.isInteger(index) || index < -1 || !Number.isInteger(count) || count < 0) throw new Error(\"unsupported store index\");\n var record = index >= 0 ? store.getAt(index) : null;\n if (index >= 0 && (!record || record.id !== path || index >= count)) throw new Error(\"inconsistent store\");\n return { dir: dir, folder: params.folder_path, count: count, index: index, record: record, selected: selections.length > 0 };\n }\n function check() {\n if (!active) return;\n if (Date.now() >= expires || !canAct()) { cancel(); return; }\n if (!win) return;\n var state = inspect();\n if (!active || !state) return;\n if (pending || state.dir !== parent || state.folder !== parent || state.index < 0) { resetQuiet(); return; }\n if (!previous || Object.keys(state).some(function (key) { return state[key] !== previous[key]; })) {\n previous = state; quietSince = Date.now();\n }\n if (Date.now() - quietSince < REVEAL_RECOVERY_QUIET_MS) return;\n var target = tuple, receiverWindow = win;\n cancel(); // Release every observer before the one permitted selection mutation.\n if (state.selected || !canAct()) return;\n target.model.selectRow(state.index);\n if (canAct() && !receiverWindow.isDestroyed && receiverWindow.getPanelInstance() === target.panel &&\n target.panel.activeView === target.view && target.panel.activeDS === target.store &&\n target.view.getSelectionModel() === target.model) target.view.scrollToItem(state.index);\n }\n function tick() {\n poll = null;\n try {\n check();\n if (active) poll = setTimeout(tick, REVEAL_RECOVERY_POLL_MS);\n } catch (error) { cancel(); }\n }\n function receive(instance) {\n if (!active || accepted) return;\n accepted = true;\n try {\n if (Date.now() >= expires || !canAct()) { cancel(); return; }\n win = instance && instance.window;\n requireMethods(win, [\"getPanelInstance\", \"getEl\", \"on\", \"un\"]);\n var owner = hostFileRevealOwners.get(win);\n if (owner && owner.order > order) { cancel(); return; }\n if (owner && owner.cancel) owner.cancel();\n hostFileRevealOwners.set(win, { order: order, cancel: cancel });\n [\"beforeclose\", \"close\", \"beforedestroy\", \"destroy\", \"hide\"].forEach(function (event) { listen(win, event, cancel); });\n check();\n } catch (error) { cancel(); }\n }\n try {\n if (!parent || !hostFileRevealOwners || typeof setTimeout !== \"function\" || typeof clearTimeout !== \"function\" || typeof Date.now !== \"function\") {\n cancel();\n } else {\n expires = Date.now() + REVEAL_RECOVERY_TIMEOUT_MS;\n deadline = setTimeout(cancel, REVEAL_RECOVERY_TIMEOUT_MS);\n poll = setTimeout(tick, REVEAL_RECOVERY_POLL_MS);\n }\n } catch (error) { cancel(); }\n return { receive: receive, cancel: cancel };\n}\n\nfunction createHostAdapter(ctx) {\n ctx = ctx || {};\n var disposed = false, subscriptions = [], revealRecovery = null;\n function launchApp(appId, config, receiverCallback) {\n if (disposed) return { status: \"unavailable\" };\n if (typeof appId !== \"string\" || !appId || /\\s/.test(appId)) return { status: \"invalid-input\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var sds = SYNO.SDS, map = sds.Config && sds.Config.FnMap, notifier = sds.StatusNotifier;\n if (typeof sds.AppLaunch !== \"function\" || !map ||\n !Object.prototype.hasOwnProperty.call(map, appId) || !map[appId] || !notifier ||\n typeof notifier.isAppEnabled !== \"function\" || !notifier.isAppEnabled(appId) ||\n typeof notifier.isAppLaunchable !== \"function\" || !notifier.isAppLaunchable(appId)) {\n return { status: \"unsupported\" };\n }\n if (receiverCallback) sds.AppLaunch(appId, config, undefined, receiverCallback);\n else sds.AppLaunch(appId, config);\n return { status: \"requested\" };\n } catch (error) { return { status: \"failed\" }; }\n }\n function navigateFileStation(path, isDirectory) {\n if (disposed) return { status: \"unavailable\" };\n if (revealRecovery) revealRecovery.cancel();\n revealRecovery = null;\n function launchFile(appId, config) {\n if (isDirectory) return launchApp(appId, config);\n var recovery = createHostFileRevealRecovery(config.openfile, function () { return !disposed && revealRecovery === recovery; });\n revealRecovery = recovery;\n var result = launchApp(appId, config, recovery.receive);\n if (result.status !== \"requested\") recovery.cancel();\n return result;\n }\n return requestHostFileNavigation(launchFile, path, isDirectory);\n }\n function unsupportedOperation() { return { status: disposed ? \"unavailable\" : \"unsupported\" }; }\n return Object.freeze({\n platform: function () {\n if (!disposed) return inspectHostPlatform(ctx);\n return Object.freeze({\n web: typeof window !== \"undefined\", host: \"unavailable\",\n dsmStandalone: null, dsmLanguage: null, appLocale: null,\n systemVersion: null, fullVersion: null, theme: null\n });\n },\n setTitle: function (title) {\n if (disposed) return { status: \"unavailable\" };\n if (typeof title !== \"string\") return { status: \"invalid-input\" };\n try {\n var platform = inspectHostPlatform(ctx);\n if (platform.host === \"dsm\") {\n if (typeof ctx.appWindow.setTitle !== \"function\") return { status: \"unsupported\" };\n ctx.appWindow.setTitle(title, undefined, true);\n return { status: \"ok\", target: \"dsm-window\" };\n }\n if (!platform.web) return { status: \"unavailable\" };\n if (typeof document === \"undefined\") return { status: \"unsupported\" };\n document.title = title;\n return { status: \"ok\", target: \"document\" };\n } catch (error) {\n return { status: \"failed\" };\n }\n },\n activateWindow: function () {\n if (disposed) return { status: \"unavailable\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var win = ctx.appWindow;\n var activate = typeof win.toFront === \"function\" ? win.toFront : win.activate;\n if (typeof activate !== \"function\") return { status: \"unsupported\" };\n // DSM minimizes by hiding; show preserves maximized geometry, unlike restore.\n if (win.hidden === true || win.minimized === true ||\n (typeof win.isVisible === \"function\" && win.isVisible() === false)) {\n if (typeof win.show !== \"function\") return { status: \"unsupported\" };\n win.show();\n }\n activate.call(win);\n return { status: \"requested\" };\n } catch (error) { return { status: \"failed\" }; }\n },\n requestClose: function () {\n if (disposed) return { status: \"unavailable\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var win = ctx.appWindow;\n // DSM returns a short-circuit value; only truthy means standalone main.\n if (typeof win.close !== \"function\" || typeof win.isStandaloneMainWindow !== \"function\" ||\n win.isStandaloneMainWindow()) return { status: \"unsupported\" };\n if (win.canClose === false) return { status: \"blocked\" };\n win.close();\n return { status: \"requested\" };\n } catch (error) { return { status: \"failed\" }; }\n },\n // DSM's own banner sign-out, for an App that hides the banner (ui.banner: hidden).\n signOut: function () {\n if (disposed) return { status: \"unavailable\" };\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n var sds = SYNO.SDS, logout = sds.Utils && sds.Utils.Logout;\n if (!logout || typeof logout.action !== \"function\") return { status: \"unsupported\" };\n if (sds.StatusNotifier) sds.StatusNotifier.fireEvent(\"logout\");\n window.onbeforeunload = sds.onBasicBeforeUnload;\n logout.action();\n return { status: \"requested\" };\n } catch (error) { return { status: \"failed\" }; }\n },\n openApp: function (appId) { return launchApp(appId, {}); },\n openURL: function (raw) {\n if (disposed || typeof window === \"undefined\") return { status: \"unavailable\" };\n if (typeof raw !== \"string\" || !raw || /[\\s\\x00-\\x1f\\x7f]/.test(raw)) return { status: \"invalid-input\" };\n if (typeof URL !== \"function\") return { status: \"unsupported\" };\n var url;\n try { url = new URL(raw); } catch (error) { return { status: \"invalid-input\" }; }\n if ((url.protocol !== \"http:\" && url.protocol !== \"https:\") || url.username || url.password) return { status: \"invalid-input\" };\n if (typeof window.open !== \"function\") return { status: \"unsupported\" };\n try {\n var opened = window.open(url.href, \"_blank\", \"noopener,noreferrer\");\n return { status: \"requested\", windowHandleAvailable: !!opened };\n } catch (error) { return { status: \"failed\" }; }\n },\n openFileManager: function (path) { return navigateFileStation(path, true); },\n revealFile: function (path) { return navigateFileStation(path, false); },\n openFile: unsupportedOperation,\n showFileDetails: unsupportedOperation,\n openAppSetting: unsupportedOperation,\n subscribe: function (event, listener) {\n if (disposed) return { status: \"unavailable\" };\n if (typeof listener !== \"function\") return { status: \"invalid-input\" };\n if (event !== \"window-resize\") return { status: \"unsupported\" };\n var win, callback = listener, active = true;\n function handler() { if (!disposed && callback) callback(); }\n function unsubscribe() {\n if (!active) return;\n active = false;\n callback = null;\n var target = win;\n win = null;\n var index = subscriptions.indexOf(unsubscribe);\n if (index !== -1) subscriptions.splice(index, 1);\n try { if (target) target.un(\"resize\", handler, target); } catch (error) {}\n }\n try {\n if (inspectHostPlatform(ctx).host !== \"dsm\") return { status: \"unsupported\" };\n win = ctx.appWindow;\n if (typeof win.on !== \"function\" || typeof win.un !== \"function\") return { status: \"unsupported\" };\n subscriptions.push(unsubscribe);\n win.on(\"resize\", handler, win);\n if (disposed) return { status: \"unavailable\" };\n return Object.freeze({ status: \"subscribed\", dispose: unsubscribe });\n } catch (error) {\n unsubscribe();\n return { status: \"failed\" };\n }\n },\n dispose: function () {\n if (disposed) return;\n disposed = true;\n if (revealRecovery) revealRecovery.cancel();\n revealRecovery = null;\n subscriptions.slice().forEach(function (unsubscribe) { unsubscribe(); });\n ctx = null;\n }\n });\n}";
|
|
3
3
|
/** A static, per-package factory for independent web shells; no Ext or transport. */
|
|
4
4
|
export declare function renderHostAdapterScript(packageId: string): string;
|
|
5
5
|
export declare function hostScriptName(build: number): string;
|
|
@@ -95,6 +95,19 @@ function createHostAdapter(ctx) {
|
|
|
95
95
|
return { status: "requested" };
|
|
96
96
|
} catch (error) { return { status: "failed" }; }
|
|
97
97
|
},
|
|
98
|
+
// DSM's own banner sign-out, for an App that hides the banner (ui.banner: hidden).
|
|
99
|
+
signOut: function () {
|
|
100
|
+
if (disposed) return { status: "unavailable" };
|
|
101
|
+
try {
|
|
102
|
+
if (inspectHostPlatform(ctx).host !== "dsm") return { status: "unsupported" };
|
|
103
|
+
var sds = SYNO.SDS, logout = sds.Utils && sds.Utils.Logout;
|
|
104
|
+
if (!logout || typeof logout.action !== "function") return { status: "unsupported" };
|
|
105
|
+
if (sds.StatusNotifier) sds.StatusNotifier.fireEvent("logout");
|
|
106
|
+
window.onbeforeunload = sds.onBasicBeforeUnload;
|
|
107
|
+
logout.action();
|
|
108
|
+
return { status: "requested" };
|
|
109
|
+
} catch (error) { return { status: "failed" }; }
|
|
110
|
+
},
|
|
98
111
|
openApp: function (appId) { return launchApp(appId, {}); },
|
|
99
112
|
openURL: function (raw) {
|
|
100
113
|
if (disposed || typeof window === "undefined") return { status: "unavailable" };
|
|
@@ -37,6 +37,7 @@ export interface HostAdapter {
|
|
|
37
37
|
setTitle(title: string): HostTitleResult;
|
|
38
38
|
activateWindow(): HostRequestResult;
|
|
39
39
|
requestClose(): HostRequestResult;
|
|
40
|
+
signOut(): HostRequestResult;
|
|
40
41
|
openApp(appId: string): HostRequestResult;
|
|
41
42
|
openURL(url: string): HostURLResult;
|
|
42
43
|
openFileManager(path: string): HostFileNavigationResult;
|
|
@@ -50,6 +50,7 @@ export interface StandaloneManifest {
|
|
|
50
50
|
aliases?: string[];
|
|
51
51
|
developedBy?: string;
|
|
52
52
|
commands: Record<string, import("../ctl/descriptor.js").Operation>;
|
|
53
|
+
external?: import("../ctl/descriptor.js").ApiPayload['external'];
|
|
53
54
|
};
|
|
54
55
|
metadata?: PackageMetadata;
|
|
55
56
|
dependencies?: PackageDependencies;
|
|
@@ -78,6 +79,8 @@ export interface StandaloneManifest {
|
|
|
78
79
|
command: string[];
|
|
79
80
|
};
|
|
80
81
|
window?: WindowSize;
|
|
82
|
+
/** `ui.banner: hidden`: a standalone page drops DSM's banner (name, account menu), as DSM's own "Hide banner" does for every App. */
|
|
83
|
+
banner?: "hidden";
|
|
81
84
|
};
|
|
82
85
|
i18n?: StandaloneI18n;
|
|
83
86
|
capabilities: Capabilities;
|
|
@@ -495,6 +495,16 @@ function readManifest(raw, c) {
|
|
|
495
495
|
c.fail("CTL_DECLARATION_INVALID", "ctl requires enabled and explicit commands.");
|
|
496
496
|
return undefined;
|
|
497
497
|
}
|
|
498
|
+
if (isRecord(raw.ctl) && raw.ctl.external !== undefined) {
|
|
499
|
+
const external = raw.ctl.external;
|
|
500
|
+
if (!isRecord(external) || Object.keys(external).length < 1 || Object.keys(external).length > 16 ||
|
|
501
|
+
Object.entries(external).some(([protocol, entry]) => !/^[a-z][a-z0-9-]{0,63}$/.test(protocol) ||
|
|
502
|
+
!isRecord(entry) || Object.keys(entry).length !== 1 || typeof entry.method !== 'string' ||
|
|
503
|
+
entry.method.length > 128 || !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(entry.method))) {
|
|
504
|
+
c.fail('CTL_EXTERNAL_DECLARATION_INVALID', 'ctl.external requires protocol names and fixed RPC methods.');
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
498
508
|
return {
|
|
499
509
|
...(raw.ctl ? { ctl: raw.ctl } : {}),
|
|
500
510
|
root: c.root,
|
|
@@ -870,8 +880,11 @@ function readUi(value, c) {
|
|
|
870
880
|
if (read && "error" in read)
|
|
871
881
|
return c.fail("STANDALONE_UI_WINDOW_INVALID", read.error);
|
|
872
882
|
const window = read ? { window: read.size } : {};
|
|
883
|
+
if (value.banner !== undefined && value.banner !== "shown" && value.banner !== "hidden")
|
|
884
|
+
return c.fail("STANDALONE_UI_BANNER_INVALID", "ui.banner must be shown or hidden.");
|
|
885
|
+
const banner = value.banner === "hidden" ? { banner: "hidden" } : {};
|
|
873
886
|
if (value.build === undefined) {
|
|
874
|
-
return dir ? { dir, ...launch, ...window } : undefined;
|
|
887
|
+
return dir ? { dir, ...launch, ...window, ...banner } : undefined;
|
|
875
888
|
}
|
|
876
889
|
const build = value.build;
|
|
877
890
|
const command = isRecord(build) ? build.command : undefined;
|
|
@@ -887,6 +900,7 @@ function readUi(value, c) {
|
|
|
887
900
|
build: { cwd, command: command },
|
|
888
901
|
...launch,
|
|
889
902
|
...window,
|
|
903
|
+
...banner,
|
|
890
904
|
}
|
|
891
905
|
: undefined;
|
|
892
906
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Local external callers for MCP
|
|
2
|
+
|
|
3
|
+
The CLI supplies a generic local bridge transport:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
synopkgland ctl external --protocol example-external-v1 --host controller --input - --json
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It accepts one UTF-8 JSON object from stdin and emits exactly one compact
|
|
10
|
+
`{ok:true,data:...}` or `{ok:false,error:{code,message,hint}}` object on stdout.
|
|
11
|
+
Input and output, including the final newline, are bounded to 1 MiB. Requests
|
|
12
|
+
have a shared 30-second network budget. A bridge should also bound the child
|
|
13
|
+
process lifetime, stdin and stderr. Invalid/duplicate options and duplicate JSON
|
|
14
|
+
keys are refused. Business arguments never belong in argv.
|
|
15
|
+
|
|
16
|
+
This command is implemented in the CLI. Apps must implement the trusted entry
|
|
17
|
+
below; the CLI does not create an MCP server, HTTP listener, shared service or
|
|
18
|
+
tool Engine. Apps without that entry remain unsupported. No SDK change or
|
|
19
|
+
automatic App upgrade is included.
|
|
20
|
+
|
|
21
|
+
## App declaration and approval
|
|
22
|
+
|
|
23
|
+
An App may declare up to 16 lowercase protocol IDs (1–64 letters/digits/dashes,
|
|
24
|
+
starting with a letter). There is no App-specific protocol dispatch. For a native
|
|
25
|
+
App with explicit ordinary ctl commands, extend `dsm-package.yaml`:
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
ctl:
|
|
29
|
+
enabled: true
|
|
30
|
+
# Keep the App's existing aliases, developedBy and commands here.
|
|
31
|
+
external:
|
|
32
|
+
example-external-v1:
|
|
33
|
+
method: bridge.external
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The existing route registry must export `bridge.external` as an administrator
|
|
37
|
+
RPC method (or have an administrator-only App entry). Build rejects missing,
|
|
38
|
+
nonadministrator and raw HTTP routes. The generated v1 API descriptor adds:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"external": {"example-external-v1": {"method": "bridge.external"}}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This is a field of `payload`, included in its existing canonical SHA-256.
|
|
47
|
+
`payload.bindings["bridge.external"]` is
|
|
48
|
+
`{"effect":"destructive","permission":"administrators"}` as a conservative
|
|
49
|
+
transport classification. The external method cannot appear as a command ID or
|
|
50
|
+
any ordinary command's invoke, job, pagination, streaming or upload helper.
|
|
51
|
+
`ctl list` displays declarations separately from ordinary commands.
|
|
52
|
+
App-owned runtime descriptors may produce this same extension. Keep their
|
|
53
|
+
packaged descriptor, permissions and registry in agreement with the live one.
|
|
54
|
+
|
|
55
|
+
The operator must first register/sign in locally, then explicitly approve the
|
|
56
|
+
specific App's fresh description with ordinary discovery, for example:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
synopkgland host login controller
|
|
60
|
+
synopkgland ctl list --host controller --app Example --refresh --approve-installed --json
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Approval authorizes sending the DSM session to that installed App's fixed CGI.
|
|
64
|
+
Obtain the user's authorization before approving an App. External calls cannot
|
|
65
|
+
approve Apps or update description pins. They match the protocol against cached
|
|
66
|
+
descriptors of approved installed package versions, then reread and verify the
|
|
67
|
+
selected live descriptor against its approved hash. Zero or multiple matching
|
|
68
|
+
Apps refuse; revoke the unwanted approval or use distinct protocol IDs. A cache
|
|
69
|
+
is only a recipient selector and never grants tool authority.
|
|
70
|
+
|
|
71
|
+
Only a named, registered host and its OS-stored session are used. Ambient
|
|
72
|
+
`DSM_*` credentials/URL cannot create or replace this login. HTTPS requires the
|
|
73
|
+
stored SHA-256 certificate pin and refuses rotation before sending HTTP headers,
|
|
74
|
+
including under ordinary ctl's permissive TLS default. Registered strict-TLS/CA
|
|
75
|
+
settings still apply. A registered explicit loopback SSH tunnel is also accepted.
|
|
76
|
+
No default-host fallback, multi-host mode, URL/TLS override, `--yes`, `--via` or
|
|
77
|
+
automatic login is available. Reapprove intentional changes outside the bridge.
|
|
78
|
+
|
|
79
|
+
## Local stdin/stdout contract
|
|
80
|
+
|
|
81
|
+
Describe input has exactly these fields:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{"protocol":"example-external-v1","action":"describe"}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Success data is the App's current Catalog, with a revision binding the entire
|
|
88
|
+
tool set:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{"ok":true,"data":{"revision":"r1","tools":[{"name":"notes__list","description":"notes.list","inputSchema":{"type":"object","additionalProperties":false},"outputSchema":{"type":"object"},"_meta":{"method":"notes.list"}}]}}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Catalog revisions contain 1–128 letters/digits/underscores/dashes. At most 1024
|
|
95
|
+
tools fit within the output budget. Each tool has a unique MCP-compatible `name`
|
|
96
|
+
(1–128 letters/digits/underscores/dashes), unique original dotted method in
|
|
97
|
+
`_meta.method`, and self-contained JSON Schema 2020-12 input/output objects.
|
|
98
|
+
Other App metadata is preserved. Schema references must be local. The App owns
|
|
99
|
+
tool eligibility, effect, schema hashes and revision construction; the bridge
|
|
100
|
+
may additionally compare these against its matching built-in OpSpecs.
|
|
101
|
+
|
|
102
|
+
Call input has exactly these fields:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{"protocol":"example-external-v1","action":"call","revision":"r1","method":"notes.list","arguments":{}}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The CLI fetches a fresh Catalog through the trusted entry, refuses stale
|
|
109
|
+
revisions/unknown methods, and validates arguments against that tool's current
|
|
110
|
+
input schema. Success data is the App's tool Result, validated against the
|
|
111
|
+
tool's output schema. The CLI never starts an ordinary ctl flow for this call.
|
|
112
|
+
`actor`, `via`, `allowMutation`, `allow_mutation` and `host` are forbidden as
|
|
113
|
+
request fields and root argument fields. They cannot establish authority through
|
|
114
|
+
nested business data either: App handlers must derive transport authority from
|
|
115
|
+
the verified server context. Model arguments are business data only.
|
|
116
|
+
|
|
117
|
+
## Trusted App RPC contract
|
|
118
|
+
|
|
119
|
+
Both actions enter only the declared method at the package's existing fixed
|
|
120
|
+
`/webman/3rdparty/<package>/cgi/api.cgi`, using the stored DSM session and
|
|
121
|
+
SynoToken. This initial implementation requires `dsm-session`; Apps using
|
|
122
|
+
`dsm-app-session` cannot declare external entries in generated descriptions.
|
|
123
|
+
|
|
124
|
+
The CLI adds installed-package and approved-description pins to RPC `params`:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{"method":"bridge.external","params":{"protocol":"example-external-v1","action":"call","revision":"r1","method":"notes.list","arguments":{},"package":{"id":"Example","version":"1"},"descriptionSha256":"<approved 64-hex hash>"}}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Describe has the same pins plus `protocol` and `action`, without call fields.
|
|
131
|
+
The App must strictly decode this envelope and, for **both** actions:
|
|
132
|
+
|
|
133
|
+
1. Authenticate the current DSM session and SynoToken, require a current
|
|
134
|
+
administrator, and establish the verified user from the SDK Caller.
|
|
135
|
+
2. Verify the protocol, installed package/version and current descriptor hash.
|
|
136
|
+
Do not echo unverified request values as evidence.
|
|
137
|
+
3. Derive MCP provenance from this dedicated entry. Never take actor, provenance,
|
|
138
|
+
scope authority or mutation intent from request JSON. Ordinary ctl must keep
|
|
139
|
+
its existing provenance and must not expose this entry as a normal tool.
|
|
140
|
+
4. Describe the eligible shared Engine tool set. For calls, atomically compare
|
|
141
|
+
revision and pins with the tool set being dispatched, before any effect.
|
|
142
|
+
CLI preflight cannot close a server-side revision race.
|
|
143
|
+
5. Resolve scope/targets, enforce explicit server-authorized mutation intent,
|
|
144
|
+
draft/selection gates and budgets, then execute the shared Engine. Human-only
|
|
145
|
+
methods must refuse guessed names; sensitive adapters return existing cards.
|
|
146
|
+
|
|
147
|
+
Use the ordinary SDK `{success,data|error}` RPC envelope. On success, `data` is:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"verified": {
|
|
152
|
+
"protocol": "example-external-v1",
|
|
153
|
+
"package": {"id": "Example", "version": "1"},
|
|
154
|
+
"descriptionSha256": "<verified 64-hex hash>",
|
|
155
|
+
"user": "<verified DSM username>",
|
|
156
|
+
"administrator": true,
|
|
157
|
+
"revision": "r1"
|
|
158
|
+
},
|
|
159
|
+
"data": {"revision":"r1","tools":[]}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
For calls the inner `data` is the tool Result and the verified revision must
|
|
164
|
+
equal the requested revision. The CLI verifies all evidence against its pins,
|
|
165
|
+
stored username and Catalog revision, then unwraps only the inner data for the
|
|
166
|
+
local bridge. Evidence is an assertion by the approved authenticated App, not
|
|
167
|
+
a signed delegation token. The server must perform these checks before dispatch;
|
|
168
|
+
returning evidence afterward cannot undo an unauthorized effect.
|
|
169
|
+
|
|
170
|
+
## Failure and verification boundary
|
|
171
|
+
|
|
172
|
+
The CLI rereads local session/host/approval state before each external RPC. It
|
|
173
|
+
does not refresh expired sessions. All errors return bounded generic text;
|
|
174
|
+
upstream messages, request arguments and exception text are never echoed.
|
|
175
|
+
Session carriers and schema-marked sensitive call values are redacted from
|
|
176
|
+
results. The App remains responsible for its own secret-free Catalog and Result.
|
|
177
|
+
Authentication failures exit 2, invalid input/options 3, trust/permission/revision
|
|
178
|
+
refusals 4, contract failures 5 and network failures 6.
|
|
179
|
+
|
|
180
|
+
There is exactly one mutation dispatch attempt. Timeout, interruption, invalid
|
|
181
|
+
evidence/schema or oversized output may follow a committed mutation; none
|
|
182
|
+
causes a retry. Query the original receipt/Operation or current state before any
|
|
183
|
+
further action. Killing the CLI stops observation and does not cancel an App
|
|
184
|
+
Operation.
|
|
185
|
+
|
|
186
|
+
Tests exercise compiled CLI subprocesses and a local TLS fixture, including
|
|
187
|
+
fresh administrator checks, revision races, lost replies and certificate
|
|
188
|
+
rotation. They do not establish any App's installed or deployed readiness.
|
|
189
|
+
|
|
190
|
+
## Syngle I-B / K9 integration checklist
|
|
191
|
+
|
|
192
|
+
- Keep the existing bridge argv and stdin/stdout shapes, with
|
|
193
|
+
`syngle-external-v1` declared under `ctl.external`; no protocol-ID rename is
|
|
194
|
+
needed. Select a fixed administrator RPC method such as `mcp.external`.
|
|
195
|
+
- Register that exact RPC in CGI and worker route composition and digest-bound
|
|
196
|
+
administrator permissions. Align generated/static and runtime descriptors,
|
|
197
|
+
their canonical hashes and package version. Keep it out of ordinary ctl flows.
|
|
198
|
+
- Strictly decode the trusted RPC params and implement the evidence envelope
|
|
199
|
+
above. Generate Describe data with the current exact `mcpbridge.NewCatalog`.
|
|
200
|
+
Compare revision/pins at the shared Engine dispatch boundary, not after effects.
|
|
201
|
+
- Construct `aitools.NewExternal(Config)` per verified Caller, preserving the
|
|
202
|
+
scope resolver, mutation-intent gate, selection/DraftGate and read-NAS budgets.
|
|
203
|
+
Keep `Authority.AllowMutation` server-authorized; do not enable it because the
|
|
204
|
+
request uses this transport. Return the original `aitools.Result` for Call.
|
|
205
|
+
- After the coordinator releases a compatible CLI, update the distribution pin
|
|
206
|
+
and setup guidance. Login alone is insufficient: include explicit App
|
|
207
|
+
discovery/approval. Preserve the bridge's exact OpSpec comparison, cancellation
|
|
208
|
+
semantics, output budget and refusal to retry lost mutations.
|
|
209
|
+
- Run composed App/bridge acceptance for expired/nonadmin sessions, forged
|
|
210
|
+
authority, stale/racing revisions, human-only guesses, ordinary `via=ctl`,
|
|
211
|
+
trusted `via=mcp`, AI disabled and lost mutation replies. Until then, report
|
|
212
|
+
Syngle end-to-end integration as pending.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.1",
|
|
4
4
|
"description": "Syno Package Land Developer Kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"assets",
|
|
15
15
|
"CHANGELOG.md",
|
|
16
16
|
"schema",
|
|
17
|
+
"docs/ctl-external.md",
|
|
17
18
|
"skills"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
30
|
"@napi-rs/keyring": "^2.1.1",
|
|
30
|
-
"@synopackageland/templates": "~0.35.
|
|
31
|
+
"@synopackageland/templates": "~0.35.1",
|
|
31
32
|
"ajv": "^8.17.1",
|
|
32
33
|
"commander": "^13.1.0",
|
|
33
34
|
"json-canonicalize": "^3.0.1",
|
|
@@ -397,6 +397,29 @@
|
|
|
397
397
|
"flow"
|
|
398
398
|
]
|
|
399
399
|
}
|
|
400
|
+
},
|
|
401
|
+
"external": {
|
|
402
|
+
"type": "object",
|
|
403
|
+
"minProperties": 1,
|
|
404
|
+
"maxProperties": 16,
|
|
405
|
+
"propertyNames": {
|
|
406
|
+
"type": "string",
|
|
407
|
+
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
|
408
|
+
},
|
|
409
|
+
"additionalProperties": {
|
|
410
|
+
"type": "object",
|
|
411
|
+
"additionalProperties": false,
|
|
412
|
+
"properties": {
|
|
413
|
+
"method": {
|
|
414
|
+
"type": "string",
|
|
415
|
+
"maxLength": 128,
|
|
416
|
+
"pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$"
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
"required": [
|
|
420
|
+
"method"
|
|
421
|
+
]
|
|
422
|
+
}
|
|
400
423
|
}
|
|
401
424
|
},
|
|
402
425
|
"required": [
|
package/skills/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
|
|
|
5
5
|
|
|
6
6
|
# Independent Synology SPKs
|
|
7
7
|
|
|
8
|
-
The CLI installs templates through `~0.35.
|
|
8
|
+
The CLI installs templates through `~0.35.1`. A templates patch needs only a
|
|
9
9
|
templates release; npm install/update selects a patch in that minor line. The CLI
|
|
10
10
|
uses the locally installed templates and reads their SDK pin for defaults; it
|
|
11
11
|
never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
|
|
@@ -24,7 +24,7 @@ Use this guide to initialize, build and inspect own applications or upstream OSS
|
|
|
24
24
|
|
|
25
25
|
For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.3 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
|
|
26
26
|
|
|
27
|
-
Use SDK/App 0.
|
|
27
|
+
Use SDK/App 0.38.0 and CLI 0.41.1 for App entry enforcement. Declare every App method
|
|
28
28
|
in the permissions table; dynamic names and unregistered HTTP dispatchers are rejected.
|
|
29
29
|
Use fixed SDK routes and `identity_gateway.upstream_auth_paths` for client API
|
|
30
30
|
paths that rely on upstream authentication. Read
|
|
@@ -41,6 +41,15 @@ cannot upgrade a connection; use an existing upgrade-capable deployment
|
|
|
41
41
|
transport. The audit follows each configured Go binary's Linux import graph,
|
|
42
42
|
including declared workers; unshipped dev commands are excluded.
|
|
43
43
|
|
|
44
|
+
SDK/App 0.38.0 allows 300 seconds for requests by default, with the browser
|
|
45
|
+
waiting 302 seconds. Explicit budgets and earlier parent deadlines still win.
|
|
46
|
+
For long work, use existing independent workers with prompt job admission,
|
|
47
|
+
progress and authorized status/result polling by the original ID. A timed-out
|
|
48
|
+
status read does not fail a queued/running job; reuse the same key/input after
|
|
49
|
+
a lost admission reply. Read [reference/create.md](reference/create.md) for
|
|
50
|
+
the deadline and background-job contract. The independent ctl transport keeps
|
|
51
|
+
its 62-second default; use an explicit timeout or an existing nonblocking job.
|
|
52
|
+
|
|
44
53
|
Use `ipc.StartUnix(ctx, path, handler)` for multiple private data-plane sockets.
|
|
45
54
|
Each direct `.sock` child of the package `var/run` gets an independent handle
|
|
46
55
|
with Close, Shutdown and Wait. Stop/revoke closes active streams and hijacked
|
|
@@ -268,6 +277,8 @@ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK
|
|
|
268
277
|
`ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
|
|
269
278
|
favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
|
|
270
279
|
|
|
280
|
+
`ui.banner: hidden` 只替這個 App 的全頁拿掉 DSM 橫幅(預設 `shown` 保留);適合 App 外框已有名稱與帳號區時,帳號與登出改由 SDK `AppShell` 負責。細節見 reference/ui-vue.md。
|
|
281
|
+
|
|
271
282
|
Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生成
|
|
272
283
|
`defaultAlias`、Portal 面板/capability 或 Web Station alias;沒有 Portal
|
|
273
284
|
不是 inspect/info/doctor 問題。明確啟用 `portal` 時才將其 alias 作為 `defaultAlias`。
|
|
@@ -62,19 +62,19 @@ backend: {runtime: rust, dir: app, entry: hello, methods: [hello.greet]}
|
|
|
62
62
|
|
|
63
63
|
## 請求期限與視窗尺寸
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
SDK/App 0.38.0 的 backend 請求期限預設 300 秒,DSM 視窗與 TS client 預設等候 302 秒。CLI 從 App 所選 SDK 的 timeout spec 讀取預設;`backend.timeout_seconds`(1–3600 的整數)可明確覆寫,go、python、node、rust 都適用,較早的 parent deadline 仍優先。到期時 App 回 `request_timeout`;DSM 視窗把工作期限放進 `ctx.requestTimeout`(毫秒),`ctx.call` 與 SDK 瀏覽器 client 多等 2 秒,讓 backend 的回覆先到。已安裝的 App、舊 SDK 或既有明確 timeout 不會自動改變;需使用匹配的 Go/TS SDK 重建。
|
|
66
66
|
|
|
67
67
|
- python、node、rust:build 把它寫進 `bridge.json` 的 `timeout_seconds`;runtime 以它為期限,傳給 App 的 `deadline_ms` 是送出呼叫時剩下的時間。
|
|
68
|
-
- go:build 以 `-X main.requestTimeout=<秒>` 傳入。要寫 `timeout_seconds`,`main.go` 必須讀這個變數,否則 check 回 `STANDALONE_TIMEOUT_UNSUPPORTED
|
|
68
|
+
- go:build 以 `-X main.requestTimeout=<秒>` 傳入。要寫 `timeout_seconds`,`main.go` 必須讀這個變數,否則 check 回 `STANDALONE_TIMEOUT_UNSUPPORTED`;未設定時使用所選 SDK 的 `api.DefaultRequestTimeout`:
|
|
69
69
|
|
|
70
70
|
```go
|
|
71
|
-
// Set by synopkgland build through -ldflags -X: backend.timeout_seconds
|
|
71
|
+
// Set by synopkgland build through -ldflags -X: backend.timeout_seconds.
|
|
72
72
|
var requestTimeout = ""
|
|
73
73
|
|
|
74
74
|
func deadline() api.DeadlineOption {
|
|
75
75
|
seconds, err := strconv.Atoi(requestTimeout)
|
|
76
76
|
if err != nil || seconds <= 0 {
|
|
77
|
-
|
|
77
|
+
return api.Timeout(api.DefaultRequestTimeout)
|
|
78
78
|
}
|
|
79
79
|
return api.Timeout(time.Duration(seconds) * time.Second)
|
|
80
80
|
}
|
|
@@ -84,6 +84,17 @@ func deadline() api.DeadlineOption {
|
|
|
84
84
|
|
|
85
85
|
- binary:預先建置的 CGI 自己決定期限,不接受 `timeout_seconds`;視窗仍等 62 秒。
|
|
86
86
|
|
|
87
|
+
長時間 NAS 工作沿用既有 jobs 與獨立 worker:
|
|
88
|
+
|
|
89
|
+
1. 驗證固定 operation 與可信 owner,以 `NewRequestStore` / `SubmitSingleton` 或 `Submit` admission,立即回覆既有 outcome 與原 job ID。
|
|
90
|
+
2. 在套件帳號的獨立 worker 或 resident daemon 執行 `Runner.Work`,用 `Task.Report` 回報 phase/progress;未知總量保持 unknown。SDK 預設 job 與 delegated download 期限為 30 分鐘,可由可信組裝設定有限期限。
|
|
91
|
+
3. 每次 status/result/cancel 都重新驗證權限,以原 ID 有界退避輪詢。`queued` / `running` 顯示仍在工作;慢 NAS 或 status read timeout 只代表狀態暫時無法取得,保留最後狀態,不判定 job 失敗。
|
|
92
|
+
4. admission 回覆遺失時,沿用相同 idempotency key 與 input,或查詢已知 ID;不可因逾時用新 key 重送已派送 mutation。只有 terminal state 能決定完成;取消無法撤回已發生的遠端效果。
|
|
93
|
+
|
|
94
|
+
直接 File Station 瀏覽器讀取預設也為 302 秒;identity gateway 等候 upstream headers 預設 310 秒,自訂期限需對齊 gateway 與外部 proxy。這些預設不保證所有 DSM/proxy 都能完成五分鐘請求。executor batch 的 60 秒上限、已核准 root policy/program budget 與 credential lifetime 維持原契約;長 root 工作使用既有 `root.start` / `root.jobs.*`。
|
|
95
|
+
|
|
96
|
+
獨立 `ctl` transport 預設仍是 62 秒。長同步呼叫須明確指定 `--timeout`,或使用既有 nonblocking job flow;參考 [ctl](ctl.md)。完整契約見 SDK 的 [request deadlines](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.38.0/docs/request-deadlines.md)。
|
|
97
|
+
|
|
87
98
|
DSM 視窗預設 960×640、最小 700×420;`ui.window` 只寫要改的欄位:
|
|
88
99
|
|
|
89
100
|
```yaml
|
package/skills/reference/ctl.md
CHANGED
|
@@ -16,6 +16,27 @@ Use `host show nas` to inspect approvals/pins, `host revoke nas PackageID` to re
|
|
|
16
16
|
|
|
17
17
|
Stdout defaults to JSON without a TTY. Success is {ok:true,data}; failure is {ok:false,error:{code,message,hint,allowed?}}. Multi-host output uses {ok,results:[{host,ok,data|error}]}. Follow the concrete hint and complete allowed list; do not guess another method or bypass ctl with raw HTTP. Exit codes: 2 authentication, 3 parameters/names, 4 permissions/trust, 5 App/contract, 6 network, 7 confirmation, 8 partial failure.
|
|
18
18
|
|
|
19
|
+
## Local MCP external callers
|
|
20
|
+
|
|
21
|
+
`synopkgland ctl external --protocol <declared-id> --host <profile> --input - --json`
|
|
22
|
+
accepts one strict JSON `describe` or revision-bound `call` on stdin and emits one
|
|
23
|
+
`{ok,data|error}` object. This requires an App-declared `ctl.external` protocol,
|
|
24
|
+
an approved package/version/description pin from ordinary discovery, and a stored
|
|
25
|
+
local login. The CLI rereads the installed inventory and descriptor and verifies
|
|
26
|
+
the App's fresh administrator evidence. HTTPS uses the registered certificate
|
|
27
|
+
pin even when ordinary NAS commands accept certificate changes. Environment
|
|
28
|
+
passwords never log in this caller; model input cannot choose the host, actor,
|
|
29
|
+
provenance or mutation authority. No `--yes`, multi-host selection, credential,
|
|
30
|
+
URL or TLS override is accepted. A lost mutation reply is never retried; query
|
|
31
|
+
the original operation/state first.
|
|
32
|
+
|
|
33
|
+
Read [the App contract](../../docs/ctl-external.md) before implementing an adapter.
|
|
34
|
+
The trusted App entry must atomically verify revision/pins, authenticate the
|
|
35
|
+
administrator and derive `via=mcp` and mutation authority server-side. Ordinary
|
|
36
|
+
ctl cannot invoke this entry through command flows. A protocol declaration alone
|
|
37
|
+
does not implement these checks or make an App MCP-ready. `syngle-external-v1`
|
|
38
|
+
works as a declared protocol; the CLI has no App-specific protocol branch.
|
|
39
|
+
|
|
19
40
|
## Plan command coverage
|
|
20
41
|
|
|
21
42
|
For packaging an upstream open-source App (including forks with `synology/`),
|
|
@@ -111,6 +132,13 @@ every App supports cancellation or singleton deduplication.
|
|
|
111
132
|
|
|
112
133
|
For AI consumers:
|
|
113
134
|
|
|
135
|
+
The independent ctl transport defaults to 62 seconds even with SDK/App 0.38.0.
|
|
136
|
+
For a long synchronous call, set `--timeout` explicitly; for an existing job
|
|
137
|
+
flow, use `--no-wait` and the original ID. A status read timeout leaves the last
|
|
138
|
+
known queued/running state intact. If the App's admission API supports an
|
|
139
|
+
idempotency key, reuse the same key and input after a lost reply; never invent
|
|
140
|
+
that support or issue a new mutation key to work around a timeout.
|
|
141
|
+
|
|
114
142
|
1. Discover the real command schema and current jobs if listing is supported.
|
|
115
143
|
Dry-run mutations, obtain authorization, then submit once with `--yes` and
|
|
116
144
|
`--no-wait` when using a job flow. Keep host, App and original operation ID.
|
|
@@ -177,6 +177,23 @@ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK
|
|
|
177
177
|
`ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
|
|
178
178
|
favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
|
|
179
179
|
|
|
180
|
+
`ui.banner: shown | hidden` 決定 App 全頁(standalone)上方要不要有 DSM 橫幅
|
|
181
|
+
(App 名稱與帳號選單)。預設 `shown`,維持 DSM 原本的樣子;`hidden` 只替這個 App
|
|
182
|
+
拿掉橫幅,用的是 DSM 全域「隱藏橫幅」同一個 class,不改 DSM 設定,DSM 桌面視窗也照樣有標題列。
|
|
183
|
+
|
|
184
|
+
什麼時候藏:App 自己的外框已經有 App 名稱與帳號區,例如用 SDK `AppShell` 的全頁 web App,
|
|
185
|
+
再疊一條 DSM 橫幅只是重複。藏掉後帳號與登出由 SDK 外框負責:`AppShell` 偵測到 DSM 沒畫帳號,
|
|
186
|
+
就在側欄底部顯示登入者與「登出」按鈕,登出呼叫 `ctx.host.signOut()`(照 DSM 橫幅選單的登出流程,
|
|
187
|
+
DSM 以外回 `unsupported`)。自己寫 `sidebar-footer` 的 App 要自己放登出,呼叫同一個方法。
|
|
188
|
+
App 沒有自己的帳號區時就保留橫幅,否則使用者會找不到登出。其他值 check 回 `STANDALONE_UI_BANNER_INVALID`。
|
|
189
|
+
|
|
190
|
+
```yaml
|
|
191
|
+
ui:
|
|
192
|
+
dir: ui/dist
|
|
193
|
+
launch: new-page
|
|
194
|
+
banner: hidden
|
|
195
|
+
```
|
|
196
|
+
|
|
180
197
|
Application Portal 是獨立 opt-in:不想要別名就省略 `portal`,不生成
|
|
181
198
|
`defaultAlias`、Portal 面板/capability 或 Web Station alias;沒有 Portal
|
|
182
199
|
不是 inspect/info/doctor 問題。明確啟用 `portal` 時才將其 alias 作為 `defaultAlias`。
|