diff --git a/openspec/changes/add-scum-file-config-workbench/design.md b/openspec/changes/add-scum-file-config-workbench/design.md index 19345f8..a583f65 100644 --- a/openspec/changes/add-scum-file-config-workbench/design.md +++ b/openspec/changes/add-scum-file-config-workbench/design.md @@ -14,16 +14,19 @@ The SCUM plugin already declares runtime config mappings and log sources, while **Non-Goals:** - Arbitrary filesystem browsing, path entry, terminals, FTP/rsync controls, raw secrets, or host/socket exposure. -- Raw text as the default configuration editor, write access for unknown fields, or a new run-side protocol. +- Raw text as the default configuration editor, direct write access for unknown fields, or a new run-side protocol. - Changing global server-list behavior or non-SCUM plugin pages. ## Decisions 1. Add `fileWorkspace` to the plugin manifest/runtime projection. It contains safe logical directories/files and modeled fields, rather than host paths or unbounded schemas. This makes a page contract auditable and keeps ownership with the plugin. 2. Give every modeled field an owning logical file key, Chinese operational metadata, and constrained control details. The frontend uses only these fields to compose proposed INI content; unmapped lines remain visible as read-only field records. -3. Reuse `GET /config`, config diff preview, and config approval for the declared primary config file. Log-file selection maps declared logical files to existing Platform log streams; no new direct file-read API is introduced. +3. Reuse declared file read/write dispatch for logical file keys. A completed `files.read` job may be projected through a Platform-owned declared-file snapshot endpoint, which accepts only a declared logical file key, returns no host path or job payload, and redacts secret-like assignment values before returning content. Modeled edits and optional raw config edits require a visible diff preview before the Platform queues a declared `files.write` job. 4. Move SCUM from `operations` to `files-config`; the route resolver redirects old `overview`, `config`, `logs`, and `operations` keys only for `game.scum`. Other plugins keep their declared page keys unchanged. 5. Build page content from existing shared console form/list/diff classes. No page-owned surface system or global decoration is added. +6. Treat the SCUM workbench as one selected logical file at a time. The left pane contains only declared directories and files. The right pane presents either modeled fields or an optional raw mode for an editable configuration file, and a read-only log view for a log file. Selection changes must not trigger unbounded polling or path-based requests. +7. Render the plugin bundle in embedded mode when it is mounted under a server-detail section. The generic plugin page frame and host-context diagnostics remain available for direct plugin routes, but must not be nested inside the file-management tab. +8. Keep Companion player, reward, state, vehicle, and trajectory controls out of this page. Those remain available through their separately scoped plugin-control experiences and must not obscure the file workflow. ## Risks / Trade-offs @@ -31,6 +34,8 @@ The SCUM plugin already declares runtime config mappings and log sources, while - [Existing installations still link old route keys] → normalize those keys in the common resolver before page lookup. - [A declared log stream is absent] → show an explicit unavailable state and never fall back to a filesystem path. - [INI parsing has formatting limits] → patch only declared simple key/value fields and rely on Platform preview before approval. +- [A raw file has not completed an authorized read] → show an explicit pending or empty state rather than an invented template or stale file content. +- [A declared file contains a secret-like assignment] → redact its value in the snapshot while retaining the surrounding file structure for operational review. ## Migration Plan diff --git a/openspec/changes/add-scum-file-config-workbench/proposal.md b/openspec/changes/add-scum-file-config-workbench/proposal.md index ad5aa0b..53a4268 100644 --- a/openspec/changes/add-scum-file-config-workbench/proposal.md +++ b/openspec/changes/add-scum-file-config-workbench/proposal.md @@ -6,8 +6,10 @@ SCUM operators currently reach separate overview, configuration, and semantic-lo - Replace the SCUM overview-first experience with a unified `文件与配置` workbench whose default scope is the plugin-declared configuration directory. - Add plugin-declared logical file directories/files and a modeled configuration-field catalog, including Chinese labels, help, control metadata, constraints, defaults, restart impact, and owning file. -- Reuse Platform-mediated file requests and the existing configuration diff-preview/approval/write flow so modeled changes are previewed before dispatch. +- Reuse Platform-mediated file requests and the existing configuration diff-preview/approval/write flow so modeled changes are previewed before dispatch; expose completed declared-file reads through a bounded, redacted Platform snapshot rather than through host paths or general job payloads. - Expose logs as declared log files inside the same workbench, with separate safe log-file scope; retain unknown configuration fields as read-only information. +- Replace the static, stacked SCUM panels with a single selection-based file workbench: directory/file navigation on one side and the selected file's modeled configuration, optional raw config mode, or read-only log content on the other. +- Embed the workbench inside the server detail section without a second plugin-page header or unrelated Companion feature panels. - Safely migrate legacy SCUM overview/config/log routes to the new workbench. ## Capabilities @@ -22,4 +24,4 @@ SCUM operators currently reach separate overview, configuration, and semantic-lo ## Impact - Affects the SCUM plugin manifest/declarations, Platform plugin validation and safe file/config DTO handling, and SCUM frontend contracts/routes/components. -- Reuses `files.request`, existing server configuration diff/approval/write APIs, and the SCUM runtime profile without expanding filesystem or remote-access authority. +- Reuses `files.request`, existing server configuration diff/approval/write APIs, and the SCUM runtime profile; adds a Platform-owned read-result projection without expanding filesystem or remote-access authority. diff --git a/openspec/changes/add-scum-file-config-workbench/specs/scum-file-config-workbench/spec.md b/openspec/changes/add-scum-file-config-workbench/specs/scum-file-config-workbench/spec.md index 8d6baa8..5de0f11 100644 --- a/openspec/changes/add-scum-file-config-workbench/specs/scum-file-config-workbench/spec.md +++ b/openspec/changes/add-scum-file-config-workbench/specs/scum-file-config-workbench/spec.md @@ -29,6 +29,50 @@ The SCUM workbench SHALL present plugin-declared log files as a log-file scope a - **WHEN** an operator selects a declared SCUM log file - **THEN** the page MUST query only its declared Platform log stream and show readable file content without host-path disclosure +### Requirement: Raw file content is a bounded declared-file snapshot +Platform SHALL expose raw configuration or log text only from the latest completed `files.read` result for the same server and a plugin-declared logical file key. + +#### Scenario: Declared file result is ready +- **WHEN** an authorized operator requests the raw view of a declared file after its read job completes +- **THEN** Platform MUST return only that file's bounded content, logical key, version, checksum, and read state +- **AND THEN** the response MUST NOT contain a host path, a generic job execution payload, or another file's content + +#### Scenario: Declared file is not read yet +- **WHEN** no successful declared-file read is available for the selected file +- **THEN** Platform MUST return an explicit pending or not-read state without inventing file contents + +#### Scenario: Raw file contains a secret-like assignment +- **WHEN** a completed declared-file result includes a secret-like `key=value` assignment +- **THEN** Platform MUST redact the assignment value before returning the raw snapshot + +### Requirement: SCUM workbench has one active file surface +The SCUM workbench SHALL keep declared directory/file navigation separate from the selected file content, rather than stacking every declared file and every unrelated SCUM feature on one page. + +#### Scenario: Operator selects a declared configuration file +- **WHEN** an operator selects `ServerSettings.ini` or another declared configuration file +- **THEN** the workbench MUST show only that file's metadata and supported modes in the content pane +- **AND THEN** modeled fields MUST be limited to fields owned by that selected file + +#### Scenario: Operator switches a selected configuration file to raw mode +- **WHEN** an operator activates the raw configuration mode for a selected declared configuration file +- **THEN** the workbench MUST show only the most recent Platform-mediated file result or an explicit not-yet-read state +- **AND THEN** an editable declared configuration file MAY expose a raw editor only after a completed read snapshot is available +- **AND THEN** raw-mode changes MUST require a visible diff preview before dispatching a declared logical `files.write` request +- **AND THEN** it MUST NOT expose a host path, arbitrary file selector, or unrestricted text editor + +#### Scenario: Operator selects a declared log file +- **WHEN** an operator selects a declared log file +- **THEN** the workbench MUST replace the configuration controls with that log file's declared read surface +- **AND THEN** the log raw view MUST stay read-only and support switching between UTF-8 and UTF-16 LE display + +### Requirement: Embedded SCUM file management avoids duplicate page chrome +When the SCUM workbench is rendered inside a server detail section, the frontend SHALL render it without a second plugin page frame, host-context panel, or unrelated Companion feature panels. + +#### Scenario: Server detail opens SCUM file management +- **WHEN** an operator opens the SCUM `文件管理` section in server detail +- **THEN** the first workbench surface MUST be the declared directory/file navigation and selected file content +- **AND THEN** the page MUST NOT render a nested `PLUGIN PAGE` title or `平台托管上下文` panel + ### Requirement: Legacy SCUM pages migrate safely The frontend SHALL migrate legacy SCUM overview, config, logs, and operations page keys to the `files-config` page while leaving non-SCUM routing unchanged. diff --git a/openspec/changes/add-scum-file-config-workbench/tasks.md b/openspec/changes/add-scum-file-config-workbench/tasks.md index fd72a32..dd1b587 100644 --- a/openspec/changes/add-scum-file-config-workbench/tasks.md +++ b/openspec/changes/add-scum-file-config-workbench/tasks.md @@ -3,14 +3,16 @@ - [x] 1.1 Add safe logical file-directory/file and modeled-field contracts to plugin manifests, Platform domain/DTO projections, copying, and validation. - [x] 1.2 Declare SCUM configuration/log files and bounded Chinese configuration field metadata, then remove legacy SCUM overview page declarations. - [x] 1.3 Add focused Platform validation/projection tests for safe declaration and field ownership behavior. +- [x] 1.4 Add a bounded redacted Platform projection for completed declared-file read results. ## 2. SCUM file and configuration workbench - [x] 2.1 Add frontend declaration contracts and resolver for the SCUM `files-config` default workbench and legacy route migration. -- [x] 2.2 Build the shared-theme file list, modeled configuration form, read-only unknown-field list, preview diff, and approval experience. +- [x] 2.2 Build the shared-theme selection-based file list, selected-file modeled configuration form, raw config mode, unknown-field list, preview diff, and declared write dispatch experience. - [x] 2.3 Add declared log-file scope and Platform log stream content reading without path exposure. +- [x] 2.4 Render the SCUM workbench embedded in server detail and keep unrelated Companion feature panels out of the file-management tab. ## 3. Verification -- [x] 3.1 Add/update focused backend, plugin, route, contract, and component tests. -- [x] 3.2 Run strict OpenSpec validation, relevant backend/plugin/frontend checks, and scripts/check-structure.sh. +- [x] 3.1 Add/update focused backend, plugin, route, contract, and component tests for selection state, raw mode, and embedded rendering. +- [x] 3.2 Run strict OpenSpec validation, relevant backend/plugin/frontend checks, browser verification, and scripts/check-structure.sh. diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 4dcc42b..421d298 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -110,6 +110,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs) mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents) mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill) + mux.HandleFunc("/api/v1/server-instances/{id}/files/read-snapshot", h.serverDeclaredFileReadSnapshot) mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff) mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove) mux.HandleFunc("/api/v1/server-instances/{id}/config", h.serverInstanceConfig) @@ -1430,6 +1431,33 @@ func (h *coreHandlers) serverInstanceConfig(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusOK, dto.ServerConfigFromDomain(config)) } +// serverDeclaredFileReadSnapshot godoc +// @Summary Read the latest declared file snapshot +// @Description Returns a redacted bounded result only for an authorized plugin-declared logical file key. +// @Tags server-instances +// @Produce json +// @Param id path string true "Server instance ID" +// @Param key query string true "Plugin-declared logical file key" +// @Success 200 {object} dto.DeclaredFileReadSnapshotResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/files/read-snapshot [get] +func (h *coreHandlers) serverDeclaredFileReadSnapshot(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + snapshot, err := h.core.GetDeclaredFileReadSnapshotForSession(bearerToken(r), r.PathValue("id"), r.URL.Query().Get("key")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.DeclaredFileReadSnapshotFromDomain(snapshot)) +} + // serverInstanceConfigDiff godoc // @Summary Preview server config diff // @Description Compares current logical server config with proposed content without dispatching a write job. diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index a24fb44..9d8e4ad 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -301,6 +301,105 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) { } } +func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + ID: "user-owner-file-snapshot-api", + DisplayName: "File Snapshot API Owner", + Email: "owner-file-snapshot-api@example.test", + Roles: []string{"server-owner"}, + Password: "secret-password", + }, adminSession) + postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ + ID: "user-other-file-snapshot-api", + DisplayName: "File Snapshot API Other", + Email: "other-file-snapshot-api@example.test", + Roles: []string{"server-admin"}, + Password: "secret-password", + }, adminSession) + ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-file-snapshot-api@example.test", Password: "secret-password"}).SessionID + otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-file-snapshot-api@example.test", Password: "secret-password"}).SessionID + + pluginRequest := validGamePluginRequest() + pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesRead) + pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"} + pluginRequest.Permissions.Files = true + pluginRequest.FileWorkspace = dto.PluginFileWorkspaceBody{ + DefaultDirectoryKey: "scum-config", + Directories: []dto.PluginLogicalDirectoryBody{ + {Key: "scum-config", Label: "服务器配置", Scope: "config"}, + {Key: "scum-logs", Label: "日志文件", Scope: "logs"}, + }, + Files: []dto.PluginLogicalFileBody{ + {Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true}, + {Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"}, + }, + ConfigFields: []dto.PluginConfigFieldBody{ + {Key: "max-players", FileKey: "scum-server-settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, DefaultValue: "128", RestartImpact: "restart-required"}, + }, + } + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest) + postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) + instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ + ID: "server-file-snapshot-api", + PluginID: "server.scum", + RunEndpointID: "run-local", + Name: "File Snapshot API Server", + State: domain.ServerInstanceStateRunning, + }, ownerSession) + + snapshot := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession) + if snapshot.State != "not-read" || snapshot.Content != "" { + t.Fatalf("expected not-read snapshot, got %+v", snapshot) + } + assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=logs/latest.log", "", ownerSession), http.StatusBadRequest, errorCodeValidation) + assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", otherSession), http.StatusForbidden, errorCodeForbidden) + + postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ + ID: "job-file-snapshot-api-read", + ServerInstanceID: instance.ID, + RunEndpointID: "run-local", + Capability: domain.JobCapabilityFilesRead, + TargetKey: "scum-server-settings", + IdempotencyKey: "idem-file-snapshot-api-read", + }) + helloRequest := validRunControlHelloRequest() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityFilesRead) + hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest)) + claim := decodeBody[dto.RunJobClaimResponse](t, performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + Capabilities: []string{domain.JobCapabilityFilesRead}, + Capacity: dto.RunCapacityResponse{MaxJobs: 4}, + })) + if !claim.HasJob || claim.Job.JobID != "job-file-snapshot-api-read" { + t.Fatalf("expected file read job claim, got %+v", claim) + } + content := "ServerName=API\nRconPassword=secret\n" + resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{ + RunEndpointID: "run-local", + SessionToken: hello.SessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + State: domain.JobStateSucceeded, + Progress: dto.JobProgressBody{Percent: 100, Message: "file read completed"}, + Message: "file read completed", + ExecutionResult: dto.RunJobExecutionResultBody{ + Kind: "file.read", + Version: 9, + SizeBytes: int64(len(content)), + Content: content, + }, + }) + assertStatus(t, resultRecorder, http.StatusOK) + ready := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession) + if ready.State != "ready" || ready.Version != 9 || !strings.Contains(ready.Content, "ServerName=API") || !strings.Contains(ready.Content, "RconPassword=") || strings.Contains(ready.Content, "secret") { + t.Fatalf("expected ready redacted snapshot, got %+v", ready) + } +} + func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { releaseBuilds := make(chan struct{}) t.Cleanup(func() { close(releaseBuilds) }) diff --git a/platform/domain/resources.go b/platform/domain/resources.go index e49b29e..87a952e 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -1030,6 +1030,22 @@ type FileOperationDispatchResult struct { Status string } +// DeclaredFileReadSnapshot is the redacted, bounded projection of a completed +// files.read job for one plugin-declared logical file. +type DeclaredFileReadSnapshot struct { + ServerInstanceID string + PluginID string + Key string + State string + Content string + Version int + Checksum string + SizeBytes int64 + JobID string + ReadAt time.Time + Reason string +} + type RunCapacity struct { MaxJobs int RunningJobs int diff --git a/platform/domain/resources.md b/platform/domain/resources.md index 76ba98d..9fdb796 100644 --- a/platform/domain/resources.md +++ b/platform/domain/resources.md @@ -114,10 +114,13 @@ Installed `GamePlugin` records persist the validated manifest `runtimeProfiles` Plugin lifecycle assets are registered as manifest-declared files plus a content-bearing registration payload. Platform packages those assets into generated Run workspaces so plugin action refs such as `actions/install.json` -and script refs such as `bin/scum-start.cmd` are available before the first -install/start job. Game-specific install/update/start policy, including SCUM -SteamCMD app IDs and launch flags, stays in the plugin asset bundle rather than -in platform services or Run executors. +and helper refs such as `bin/scum-install-update.cmd` are available before the +first bootstrap job. Guided deployments whose selected lifecycle profile +supports `process.start` may bootstrap through the plugin-owned start action so +the script can install-if-missing and then launch the supervised process whose +stdout/stderr feed the live terminal. Game-specific install/update/start policy, +including SCUM SteamCMD app IDs and launch flags, stays in the plugin asset +bundle rather than in platform services or Run executors. Bindings are used for action gating and future run-side profile resolution. File and MySQL metadata snapshots include them so a platform restart does not make a configured server appear complete or lose its selected profile. API responses expose only logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never expose stored binding values, raw host paths, direct sockets, FTP/RCON passwords, SQL DSNs, component auth keys, or internal secret locations. @@ -161,8 +164,8 @@ Dependency checks and installs are queued as run jobs with logical `dependencies Lifecycle workflow jobs use fixed capabilities: -- `process.install`: dispatched by server create workflow and projects successful terminal results to `ready`. -- `process.start`: dispatched by server start workflow and projects successful terminal results to `running`. +- `process.install`: dispatched by install bootstrap workflows and projects successful terminal results to `ready`. +- `process.start`: dispatched by server start workflow or guided supervised bootstrap and projects successful terminal results to `running`. - `process.stop`: dispatched by server stop workflow and projects successful terminal results to `stopped`. - `run.self-update`: dispatched by runtime distribution APIs with an approved artifact ref and checksum. - `dependencies.check`: dispatched by dependency check APIs for a declared probe key. diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 4d5d833..c33a703 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -703,6 +703,20 @@ type FileOperationDispatchResponse struct { Job JobResponse `json:"job"` } +type DeclaredFileReadSnapshotResponse struct { + ServerInstanceID string `json:"serverInstanceId"` + PluginID string `json:"pluginId"` + Key string `json:"key"` + State string `json:"state"` + Content string `json:"content,omitempty"` + Version int `json:"version,omitempty"` + Checksum string `json:"checksum,omitempty"` + SizeBytes int64 `json:"sizeBytes,omitempty"` + JobID string `json:"jobId,omitempty"` + ReadAt time.Time `json:"readAt,omitempty"` + Reason string `json:"reason,omitempty"` +} + type RunCapacityResponse struct { MaxJobs int `json:"maxJobs"` RunningJobs int `json:"runningJobs"` @@ -1756,6 +1770,22 @@ func FileOperationDispatchFromDomain(result domain.FileOperationDispatchResult) } } +func DeclaredFileReadSnapshotFromDomain(snapshot domain.DeclaredFileReadSnapshot) DeclaredFileReadSnapshotResponse { + return DeclaredFileReadSnapshotResponse{ + ServerInstanceID: snapshot.ServerInstanceID, + PluginID: snapshot.PluginID, + Key: snapshot.Key, + State: snapshot.State, + Content: snapshot.Content, + Version: snapshot.Version, + Checksum: snapshot.Checksum, + SizeBytes: snapshot.SizeBytes, + JobID: snapshot.JobID, + ReadAt: snapshot.ReadAt, + Reason: snapshot.Reason, + } +} + func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpointResponse { endpoint = domain.CopyRunEndpoint(endpoint) return RunEndpointResponse{ diff --git a/platform/protocol/server-deployment.md b/platform/protocol/server-deployment.md index 74e3813..0221b4f 100644 --- a/platform/protocol/server-deployment.md +++ b/platform/protocol/server-deployment.md @@ -6,11 +6,12 @@ when a server uses `custom-command` mode and Run executes the operator-reviewed argv-oriented command inside its local policy. Guided or existing-server game setup is plugin-owned. Platform dispatches the -plugin-declared lifecycle action reference, such as `actions/install.json`, and -may include generic deployment context (`mode`, `profileKey`, `serverRoot`, and -`createInputs`) so the plugin action can resolve its own behavior. Platform does -not create a game-specific `serverDeploymentPlan`, and SCUM no longer requires a -`deployment.scum.v1` capability. +plugin-declared lifecycle action reference, such as `actions/install.json` or a +supervised `actions/start.json` bootstrap, and may include generic deployment +context (`mode`, `profileKey`, `serverRoot`, and `createInputs`) so the plugin +action can resolve its own behavior. Platform does not create a game-specific +`serverDeploymentPlan`, and SCUM no longer requires a `deployment.scum.v1` +capability. ## Capability and policy diff --git a/platform/protocol/server-lifecycle.md b/platform/protocol/server-lifecycle.md index d61e678..9563a6e 100644 --- a/platform/protocol/server-lifecycle.md +++ b/platform/protocol/server-lifecycle.md @@ -19,9 +19,9 @@ A server instance is created from one installed game management plugin and bound ### States -- `draft`: instance record exists but install job has not completed. -- `installing`: run install job is active. -- `ready`: install succeeded and the server can start. +- `draft`: instance record exists but the first bootstrap job has not completed. +- `installing`: the first plugin-owned bootstrap job is active. +- `ready`: install/bootstrap succeeded without starting a supervised process, and the server can start. - `running`: server process is running. - `stopped`: server process is stopped. - `failed`: last lifecycle operation failed. @@ -36,7 +36,7 @@ A server instance is created from one installed game management plugin and bound ## Lifecycle Actions -- `create`: validate plugin, create instance record, dispatch install job. +- `create`: validate plugin, create instance record, dispatch the plugin-owned bootstrap job. - `start`: dispatch process start job through the bound run endpoint. - `stop`: dispatch process stop job through the bound run endpoint. - `restart`: dispatch stop/start or plugin-defined restart job. @@ -48,7 +48,7 @@ A server instance is created from one installed game management plugin and bound - `GET /api/v1/plugin-marketplace/plugins` lists plugin marketplace summaries from registry metadata with status, server type, capability, and keyword filters. - `GET /api/v1/plugin-marketplace/plugins/{id}` returns one registry-backed marketplace detail. - `POST /api/v1/plugin-marketplace/plugins/{id}/state` applies metadata-only `install`, `enable`, or `disable` state changes. -- `POST /api/v1/server-instances/workflows/create` validates an installed plugin, a compatible run endpoint, a non-empty idempotency key, and required lifecycle action references. It creates the instance in `installing` state and queues a `process.install` job. +- `POST /api/v1/server-instances/workflows/create` validates an installed plugin, a compatible run endpoint, a non-empty idempotency key, and required lifecycle action references. It creates the instance in `installing` state and queues either `process.install` or, for guided deployments whose selected lifecycle profile supports supervised start, `process.start` so the plugin start script can install-if-missing and stream process logs. - `POST /api/v1/server-instances/{id}/start` validates the instance is `ready` or `stopped`, checks the expected config version, verifies the plugin start action and run endpoint `process.start` capability, and queues a start job. - `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job. - `GET /api/v1/server-instances/{id}/config` returns logical read-only config content for an authorized server instance with config version, format, key, source, and update timestamp metadata. diff --git a/platform/service/control.go b/platform/service/control.go index 2a74ca2..6484481 100644 --- a/platform/service/control.go +++ b/platform/service/control.go @@ -130,7 +130,8 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R // queueManagedGuidedDeploymentAfterRegistration advances only a newly-created, // dedicated guided server. Selecting guided-install is the owner's prior -// authorization for this bounded write; reconnects remain idempotent. +// authorization for the plugin-declared bootstrap action; reconnects remain +// idempotent. func (svc *CoreService) queueManagedGuidedDeploymentAfterRegistration(hello domain.RunControlHello) error { if hello.ComponentKind != domain.DistributionComponentRun || strings.TrimSpace(hello.ServerInstanceID) == "" { return nil diff --git a/platform/service/control_test.go b/platform/service/control_test.go index 471afdd..6b905c8 100644 --- a/platform/service/control_test.go +++ b/platform/service/control_test.go @@ -291,16 +291,16 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly( registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID) stored, err := svc.GetServerInstance(guided.Instance.ID) if err != nil || stored.State != domain.ServerInstanceStateInstalling { - t.Fatalf("guided registration should queue install, server=%+v err=%v", stored, err) + t.Fatalf("guided registration should queue bootstrap start, server=%+v err=%v", stored, err) } jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID}) - if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall { - t.Fatalf("expected one automatic install job, jobs=%+v err=%v", jobs, err) + if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStart || jobs[0].TargetKey != "actions/start.json" || len(jobs[0].ExecutionInput.LogSources) != 2 { + t.Fatalf("expected one automatic supervised start job, jobs=%+v err=%v", jobs, err) } registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID) jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID}) if len(jobs) != 1 { - t.Fatalf("Run reconnect must not duplicate automatic install, jobs=%+v", jobs) + t.Fatalf("Run reconnect must not duplicate automatic bootstrap, jobs=%+v", jobs) } generatedRunDraft := domain.ServerInstance{ @@ -319,11 +319,11 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly( registerDedicatedRunForTest(t, svc, generatedRunDraft, plugin.ID) storedGenerated, err := svc.GetServerInstance(generatedRunDraft.ID) if err != nil || storedGenerated.State != domain.ServerInstanceStateInstalling { - t.Fatalf("generated Run registration should queue install without deployment target, server=%+v err=%v", storedGenerated, err) + t.Fatalf("generated Run registration should queue supervised start without deployment target, server=%+v err=%v", storedGenerated, err) } jobs, err = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: generatedRunDraft.ID}) - if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall || jobs[0].ExecutionInput.WorkspaceScope != "local" { - t.Fatalf("expected one scoped automatic generated Run install job, jobs=%+v err=%v", jobs, err) + if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityStart || jobs[0].ExecutionInput.WorkspaceScope != "local" { + t.Fatalf("expected one scoped automatic generated Run start job, jobs=%+v err=%v", jobs, err) } existing, err := svc.CreateServerInstanceWorkflowForSession(owner, domain.ServerLifecycleCreate{ID: "managed-existing", PluginID: plugin.ID, DeploymentTargetID: "run-local", Name: "Managed Existing", IdempotencyKey: "managed-existing-create", ProfileKey: "local", Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: "C:\\existing-scum"}}) @@ -337,7 +337,7 @@ func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly( } } -func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T) { +func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedStart(t *testing.T) { svc := newTestCoreService() plugin := scumDeploymentTestPlugin() plugin.Name = "SCUM" @@ -424,22 +424,22 @@ func TestCoreServiceGeneratedSCUMRunRegistrationQueuesGuidedInstall(t *testing.T stored, err := svc.GetServerInstance(instance.ID) if err != nil || stored.State != domain.ServerInstanceStateInstalling { - t.Fatalf("generated SCUM registration should queue install, server=%+v err=%v", stored, err) + t.Fatalf("generated SCUM registration should queue supervised bootstrap start, server=%+v err=%v", stored, err) } jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) if err != nil || len(jobs) != 1 { - t.Fatalf("expected one SCUM install job, jobs=%+v err=%v", jobs, err) + t.Fatalf("expected one SCUM start job, jobs=%+v err=%v", jobs, err) } job := jobs[0] - if job.Capability != domain.LifecycleCapabilityInstall || job.TargetKey != "actions/install.json" || job.ExecutionInput.WorkspaceScope != "run-local" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil { - t.Fatalf("expected SCUM install job with scoped plugin action and generic deployment inputs, job=%+v", job) + if job.Capability != domain.LifecycleCapabilityStart || job.TargetKey != "actions/start.json" || job.ExecutionInput.WorkspaceScope != "run-local" || job.ExecutionInput.Deployment == nil || job.ExecutionInput.ServerDeploymentPlan != nil || len(job.ExecutionInput.LogSources) == 0 { + t.Fatalf("expected SCUM supervised start job with scoped plugin action and generic deployment inputs, job=%+v", job) } if job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" { - t.Fatalf("SCUM install job lost create inputs: %+v", job.ExecutionInput.Deployment.CreateInputs) + t.Fatalf("SCUM start job lost create inputs: %+v", job.ExecutionInput.Deployment.CreateInputs) } claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, Capabilities: hello.CapabilityReport.Capabilities, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.WorkspaceScope != "run-local" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil { - t.Fatalf("generated SCUM Run should claim scoped plugin-owned install action, claim=%+v err=%v", claim, err) + if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/start.json" || claim.Job.ExecutionInput.WorkspaceScope != "run-local" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil { + t.Fatalf("generated SCUM Run should claim scoped plugin-owned start action, claim=%+v err=%v", claim, err) } } diff --git a/platform/service/resources.go b/platform/service/resources.go index 3f33225..e259e75 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -123,6 +123,7 @@ type Core interface { ListRemoteAdapterDeclarationsForSession(string, string) ([]domain.RemoteAdapterDeclaration, error) RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error) GetServerConfigForSession(string, string) (domain.ServerConfig, error) + GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error) PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error) DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error) @@ -1883,6 +1884,120 @@ func (svc *CoreService) GetServerConfigForSession(sessionID string, serverInstan return domain.CopyServerConfig(config), nil } +func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, serverInstanceID string, fileKey string) (domain.DeclaredFileReadSnapshot, error) { + instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) + if err != nil { + return domain.DeclaredFileReadSnapshot{}, err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return domain.DeclaredFileReadSnapshot{}, err + } + if plugin.Status != domain.GamePluginStatusInstalled || (!plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read")) { + return domain.DeclaredFileReadSnapshot{}, ErrForbidden + } + file, constrained, allowed := declaredPluginFileRequest(plugin.FileWorkspace, domain.FileOperationDispatchRequest{Operation: domain.FileOperationRead, Key: fileKey}) + if !constrained || !allowed || file.Key == "" { + return domain.DeclaredFileReadSnapshot{}, validationError("file key must reference a plugin-declared file") + } + jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) + if err != nil { + return domain.DeclaredFileReadSnapshot{}, err + } + var completed *domain.Job + var pending *domain.Job + for i := range jobs { + job := jobs[i] + if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != file.Key { + continue + } + if job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" { + if completed == nil || newerJob(job, *completed) { + copy := job + completed = © + } + continue + } + if declaredFileReadPendingState(job.State) && (pending == nil || newerJob(job, *pending)) { + copy := job + pending = © + } + } + base := domain.DeclaredFileReadSnapshot{ServerInstanceID: instance.ID, PluginID: plugin.ID, Key: file.Key} + if completed != nil { + return domain.DeclaredFileReadSnapshot{ + ServerInstanceID: base.ServerInstanceID, + PluginID: base.PluginID, + Key: base.Key, + State: "ready", + Content: redactDeclaredFileReadContent(completed.ExecutionResult.Content), + Version: completed.ExecutionResult.Version, + Checksum: completed.ExecutionResult.Checksum, + SizeBytes: completed.ExecutionResult.SizeBytes, + JobID: completed.ID, + ReadAt: jobCompletedAt(*completed), + }, nil + } + if pending != nil { + base.State = "pending" + base.JobID = pending.ID + base.Reason = "等待运行端完成文件读取。" + return base, nil + } + base.State = "not-read" + base.Reason = "尚未读取此声明文件。" + return base, nil +} + +func declaredFileReadPendingState(state domain.JobState) bool { + switch state { + case domain.JobStateQueued, domain.JobStateAccepted, domain.JobStateRunning, domain.JobStateRetrying: + return true + default: + return false + } +} + +func newerJob(left domain.Job, right domain.Job) bool { + leftTime, rightTime := jobCompletedAt(left), jobCompletedAt(right) + if !leftTime.Equal(rightTime) { + return leftTime.After(rightTime) + } + return left.ID > right.ID +} + +func jobCompletedAt(job domain.Job) time.Time { + if !job.TerminalAt.IsZero() { + return job.TerminalAt + } + if !job.UpdatedAt.IsZero() { + return job.UpdatedAt + } + return job.CreatedAt +} + +func redactDeclaredFileReadContent(content string) string { + lines := strings.Split(content, "\n") + for index, line := range lines { + key, _, found := strings.Cut(line, "=") + if !found || !secretLikeFileAssignmentKey(key) { + continue + } + lines[index] = key + "=" + } + return strings.Join(lines, "\n") +} + +func secretLikeFileAssignmentKey(key string) bool { + normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(key), "_", ""), "-", "")) + for _, marker := range []string{"password", "passwd", "secret", "token", "apikey", "accesskey", "privatekey", "rcon"} { + if strings.Contains(normalized, marker) { + return true + } + } + return false +} + func (svc *CoreService) PreviewServerConfigWriteForSession(sessionID string, request domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) { if request.Key == "" { request.Key = "server.properties" @@ -2022,6 +2137,12 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") { return domain.FileOperationDispatchResult{}, ErrForbidden } + if file, constrained, allowed := declaredPluginFileRequest(plugin.FileWorkspace, request); constrained && !allowed { + if file.Key == "" { + return domain.FileOperationDispatchResult{}, validationError("file key must reference a plugin-declared file") + } + return domain.FileOperationDispatchResult{}, validationError("file key is not writable by plugin declaration") + } } capability := domain.JobCapabilityFilesRead message := "file read queued" @@ -2054,6 +2175,22 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques }), nil } +func declaredPluginFileRequest(workspace domain.PluginFileWorkspace, request domain.FileOperationDispatchRequest) (domain.PluginLogicalFile, bool, bool) { + if len(workspace.Files) == 0 { + return domain.PluginLogicalFile{}, false, true + } + for _, file := range workspace.Files { + if file.Key != request.Key { + continue + } + if request.Operation == domain.FileOperationWrite && (file.Kind != "config" || !file.Editable) { + return file, true, false + } + return file, true, true + } + return domain.PluginLogicalFile{}, true, false +} + func (svc *CoreService) runtimeProfileScope(serverInstanceID string) string { binding, err := svc.runtimeBindingForServer(serverInstanceID) if err != nil { diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index f5aa7f6..f60936d 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -839,6 +839,165 @@ func TestCoreServiceConfigWriteAndFileDispatchAreScoped(t *testing.T) { } } +func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + plugin.FileWorkspace = scumTestFileWorkspace() + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update plugin workspace: %v", err) + } + ownerSession := createServiceUserAndLogin(t, svc, domain.User{ + ID: "user-owner-file-workspace", + DisplayName: "File Workspace Owner", + Email: "file-workspace-owner@example.test", + Roles: []string{"server-owner"}, + PasswordHash: "secret-password", + }) + instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ + ID: "server-file-workspace", + PluginID: plugin.ID, + RunEndpointID: endpoint.ID, + Name: "File Workspace Server", + State: domain.ServerInstanceStateRunning, + }) + if err != nil { + t.Fatalf("create server: %v", err) + } + createCompleteRuntimeBinding(t, svc, instance, "local") + + allowed, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ + ServerInstanceID: instance.ID, + PluginID: plugin.ID, + Operation: domain.FileOperationRead, + Key: "scum-server-settings", + IdempotencyKey: "idem-file-workspace-read", + }) + if err != nil { + t.Fatalf("dispatch declared file read: %v", err) + } + if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.Capability != domain.JobCapabilityFilesRead { + t.Fatalf("unexpected declared file dispatch: %+v", allowed) + } + if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ + ServerInstanceID: instance.ID, + PluginID: plugin.ID, + Operation: domain.FileOperationRead, + Key: "logs/latest.log", + IdempotencyKey: "idem-file-workspace-unknown", + }); err == nil || !strings.Contains(err.Error(), "plugin-declared file") { + t.Fatalf("expected undeclared file key rejection, got %v", err) + } + if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ + ServerInstanceID: instance.ID, + PluginID: plugin.ID, + Operation: domain.FileOperationWrite, + Key: "scum-chat-log", + InputRef: "input://file-workspace/update", + Content: "line", + IdempotencyKey: "idem-file-workspace-log-write", + }); err == nil || !strings.Contains(err.Error(), "not writable") { + t.Fatalf("expected log write rejection, got %v", err) + } +} + +func TestDeclaredFileReadSnapshotProjectionStatesAndRedaction(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + plugin.FileWorkspace = scumTestFileWorkspace() + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update plugin workspace: %v", err) + } + ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-snapshot-owner", DisplayName: "File Snapshot Owner", Email: "file-snapshot-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-snapshot-other", DisplayName: "File Snapshot Other", Email: "file-snapshot-other@example.test", Roles: []string{"server-admin"}, PasswordHash: "secret-password"}) + instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-snapshot", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File Snapshot Server", State: domain.ServerInstanceStateRunning}) + if err != nil { + t.Fatalf("create server: %v", err) + } + + snapshot, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings") + if err != nil || snapshot.State != "not-read" { + t.Fatalf("expected not-read without jobs, snapshot=%+v err=%v", snapshot, err) + } + queued := createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-queued", domain.JobStateQueued, 1, "") + snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings") + if err != nil || snapshot.State != "pending" || snapshot.JobID != queued.ID { + t.Fatalf("expected pending queued job, snapshot=%+v err=%v", snapshot, err) + } + queued.State = domain.JobStateFailed + queued.UpdatedAt = fixedTime.Add(2 * time.Minute) + queued.TerminalAt = fixedTime.Add(2 * time.Minute) + if err := svc.store.Jobs().Update(queued); err != nil { + t.Fatalf("update failed read job: %v", err) + } + createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-cancelled", domain.JobStateCancelled, 3, "") + snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings") + if err != nil || snapshot.State != "not-read" { + t.Fatalf("failed/cancelled reads must not mask not-read, snapshot=%+v err=%v", snapshot, err) + } + createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-old", domain.JobStateSucceeded, 4, "ServerName=Old\nRconPassword=secret\n") + createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-failed-newer", domain.JobStateFailed, 5, "") + snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings") + if err != nil || snapshot.State != "ready" || snapshot.JobID != "job-file-snapshot-success-old" || !strings.Contains(snapshot.Content, "RconPassword=") { + t.Fatalf("expected older successful redacted result, snapshot=%+v err=%v", snapshot, err) + } + createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-new", domain.JobStateSucceeded, 6, "ServerName=New\nApiToken=secret\n") + snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings") + if err != nil || snapshot.JobID != "job-file-snapshot-success-new" || !strings.Contains(snapshot.Content, "ServerName=New") || strings.Contains(snapshot.Content, "secret") { + t.Fatalf("expected newest successful redacted result, snapshot=%+v err=%v", snapshot, err) + } + if _, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "logs/latest.log"); err == nil || !strings.Contains(err.Error(), "plugin-declared file") { + t.Fatalf("expected unknown logical key rejection, got %v", err) + } + if _, err := svc.GetDeclaredFileReadSnapshotForSession(otherSession, instance.ID, "scum-server-settings"); !errors.Is(err, ErrForbidden) { + t.Fatalf("expected unrelated session forbidden, got %v", err) + } +} + +func scumTestFileWorkspace() domain.PluginFileWorkspace { + return domain.PluginFileWorkspace{ + DefaultDirectoryKey: "scum-config", + Directories: []domain.PluginLogicalDirectory{ + {Key: "scum-config", Label: "服务器配置", Scope: "config"}, + {Key: "scum-logs", Label: "日志文件", Scope: "logs"}, + }, + Files: []domain.PluginLogicalFile{ + {Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true}, + {Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"}, + }, + ConfigFields: []domain.PluginConfigField{ + {Key: "max-players", FileKey: "scum-server-settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, DefaultValue: "128", RestartImpact: "restart-required"}, + }, + } +} + +func createDeclaredFileReadJob(t *testing.T, svc *CoreService, instance domain.ServerInstance, endpoint domain.RunEndpoint, id string, state domain.JobState, minuteOffset int, content string) domain.Job { + t.Helper() + job, err := svc.CreateJob(domain.Job{ + ID: id, + ServerInstanceID: instance.ID, + RunEndpointID: endpoint.ID, + Capability: domain.JobCapabilityFilesRead, + TargetKey: "scum-server-settings", + IdempotencyKey: id, + }) + if err != nil { + t.Fatalf("create declared file read job: %v", err) + } + stamp := fixedTime.Add(time.Duration(minuteOffset) * time.Minute) + job.State = state + job.UpdatedAt = stamp + if state == domain.JobStateSucceeded || state == domain.JobStateFailed || state == domain.JobStateCancelled { + job.TerminalAt = stamp + } + if state == domain.JobStateSucceeded { + job.ExecutionResult = domain.JobExecutionResult{Kind: "file.read", Version: minuteOffset, Checksum: validator.BytesChecksum([]byte(content)), SizeBytes: int64(len(content)), Content: content} + } + if err := svc.store.Jobs().Update(job); err != nil { + t.Fatalf("update declared file read job: %v", err) + } + return job +} + func TestConfigWriteTerminalResultAppliesDurableTypedProjection(t *testing.T) { svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) diff --git a/platform/service/server_deployment.go b/platform/service/server_deployment.go index 1419244..051be89 100644 --- a/platform/service/server_deployment.go +++ b/platform/service/server_deployment.go @@ -85,8 +85,8 @@ func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command } // deployServerInstance is the platform-owned transition from a saved deployment -// definition to one fenced install job. Callers must already have established -// the authority to act for the server. +// definition to one fenced plugin-owned bootstrap job. Callers must already +// have established the authority to act for the server. func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) { if err := validator.ValidateServerLifecycleCommand(command); err != nil { return domain.ServerLifecycleResult{}, err @@ -118,16 +118,17 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma return domain.ServerLifecycleResult{}, err } instance.Deployment = applyPluginCreateDefaults(plugin, instance.Deployment) - if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil { + bootstrapAction := deploymentBootstrapLifecycleAction(plugin, instance) + if err := validateLifecycleActionRef(plugin, bootstrapAction); err != nil { return domain.ServerLifecycleResult{}, err } - if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); err != nil { + if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, bootstrapAction); err != nil { return domain.ServerLifecycleResult{}, err } - if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityInstall); err != nil { + if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil { return domain.ServerLifecycleResult{}, err } - if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, command.IdempotencyKey, instance.ID, domain.LifecycleCapabilityInstall); err != nil { + if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, command.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil { return domain.ServerLifecycleResult{}, err } if strings.TrimSpace(instance.Deployment.ProfileKey) != "" && deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) { @@ -153,7 +154,7 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma if err := svc.store.ServerInstances().Update(instance); err != nil { return domain.ServerLifecycleResult{}, err } - job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, command.IdempotencyKey) + job, err := svc.dispatchLifecycleJob(instance, bootstrapAction, command.IdempotencyKey) if err != nil { return domain.ServerLifecycleResult{}, err } diff --git a/platform/service/server_deployment_test.go b/platform/service/server_deployment_test.go index 9a65ebd..5100db4 100644 --- a/platform/service/server_deployment_test.go +++ b/platform/service/server_deployment_test.go @@ -99,7 +99,7 @@ func TestCoreServiceUpdatesDeploymentWhileServerIsActive(t *testing.T) { } } -func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testing.T) { +func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedSupervisedStartAction(t *testing.T) { svc := newTestCoreService() runHello := validRunControlHello() runHello.RunEndpointID = "run-scum-guided" @@ -132,15 +132,15 @@ func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testin if err != nil { t.Fatalf("SCUM guided deployment should not require unrelated manifest capabilities: %v", err) } - if created.Job.TargetKey != "actions/install.json" || created.Job.ExecutionInput.ServerDeploymentPlan != nil || created.Job.ExecutionInput.Deployment == nil { - t.Fatalf("expected plugin-owned install action without SCUM deployment plan, job=%+v", created.Job) + if created.Job.Capability != domain.LifecycleCapabilityStart || created.Job.TargetKey != "actions/start.json" || created.Job.ExecutionInput.ServerDeploymentPlan != nil || created.Job.ExecutionInput.Deployment == nil || len(created.Job.ExecutionInput.LogSources) == 0 { + t.Fatalf("expected plugin-owned supervised start action without SCUM deployment plan, job=%+v", created.Job) } if created.Job.ExecutionInput.Deployment.CreateInputs["gamePort"] != "27000" || created.Job.ExecutionInput.Deployment.CreateInputs["maxPlayers"] != "128" { - t.Fatalf("SCUM install job lost create inputs: %+v", created.Job.ExecutionInput.Deployment.CreateInputs) + t.Fatalf("SCUM start job lost create inputs: %+v", created.Job.ExecutionInput.Deployment.CreateInputs) } - claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-scum-guided", SessionToken: session.SessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/install.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil { - t.Fatalf("claimed SCUM install must use plugin action without SCUM plan, claim=%+v err=%v", claim, err) + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-scum-guided", SessionToken: session.SessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job.TargetKey != "actions/start.json" || claim.Job.ExecutionInput.ServerDeploymentPlan != nil { + t.Fatalf("claimed SCUM start must use plugin action without SCUM plan, claim=%+v err=%v", claim, err) } } @@ -163,11 +163,11 @@ func TestCoreServiceDeploymentLifecycleFailureDoesNotRequireExecutionReceipt(t * if err != nil { t.Fatalf("create deployment lifecycle server: %v", err) } - claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, Capacity: domain.RunCapacity{MaxJobs: 1}}) if err != nil || !claim.HasJob || claim.Job.JobID != created.Job.ID || claim.Job.ExecutionInput.Deployment == nil { t.Fatalf("claim deployment lifecycle job: claim=%+v err=%v", claim, err) } - if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "installing"}); err != nil { + if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "starting"}); err != nil { t.Fatalf("ack deployment lifecycle job: %v", err) } result, err := svc.CompleteRunJob(domain.RunJobResult{ @@ -215,11 +215,11 @@ func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t if err != nil { t.Fatalf("create guided lifecycle server: %v", err) } - claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, Capacity: domain.RunCapacity{MaxJobs: 1}}) if err != nil || !claim.HasJob || claim.Job.JobID != created.Job.ID || claim.Job.ExecutionInput.Deployment == nil { t.Fatalf("claim guided lifecycle job: claim=%+v err=%v", claim, err) } - if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "installing"}); err != nil { + if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "starting"}); err != nil { t.Fatalf("ack guided lifecycle job: %v", err) } if _, err := svc.CompleteRunJob(domain.RunJobResult{ @@ -232,8 +232,8 @@ func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t if err != nil { t.Fatalf("get ready guided instance: %v", err) } - if instance.State != domain.ServerInstanceStateReady { - t.Fatalf("expected guided plugin lifecycle success to mark ready, got %+v", instance) + if instance.State != domain.ServerInstanceStateRunning { + t.Fatalf("expected guided plugin lifecycle success to mark running, got %+v", instance) } } diff --git a/platform/service/server_lifecycle.go b/platform/service/server_lifecycle.go index 20e50ca..9fcb707 100644 --- a/platform/service/server_lifecycle.go +++ b/platform/service/server_lifecycle.go @@ -109,13 +109,17 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc if err != nil { return domain.ServerLifecycleResult{}, fmt.Errorf("get run endpoint dependency: %w", err) } - if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, domain.ServerLifecycleActionCreate); err != nil { + bootstrapAction := deploymentBootstrapLifecycleAction(plugin, instance) + if err := validateLifecycleActionRef(plugin, bootstrapAction); err != nil { return domain.ServerLifecycleResult{}, err } - if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { + if err := validateServerInstanceLifecycleDependencies(instance, plugin, endpoint, bootstrapAction); err != nil { return domain.ServerLifecycleResult{}, err } - if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { + if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil { + return domain.ServerLifecycleResult{}, err + } + if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(bootstrapAction)); err != nil { return domain.ServerLifecycleResult{}, err } var binding domain.RuntimeBinding @@ -135,7 +139,7 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc } } - job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, create.IdempotencyKey) + job, err := svc.dispatchLifecycleJob(instance, bootstrapAction, create.IdempotencyKey) if err != nil { return domain.ServerLifecycleResult{}, err } @@ -358,6 +362,20 @@ func lifecycleProcessLogSources(profiles domain.GamePluginRuntimeProfiles) []dom return sources } +func deploymentBootstrapLifecycleAction(plugin domain.GamePlugin, instance domain.ServerInstance) domain.ServerLifecycleAction { + if instance.Deployment.Mode != domain.ServerDeploymentModeGuided { + return domain.ServerLifecycleActionCreate + } + profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, instance.Deployment.ProfileKey) + if !exists || !containsString(profile.Capabilities, domain.LifecycleCapabilityStart) { + return domain.ServerLifecycleActionCreate + } + if strings.TrimSpace(runtimeProfileActionRef(profile.ActionRefs, domain.ServerLifecycleActionStart)) == "" && strings.TrimSpace(plugin.LifecycleActions.Start) == "" { + return domain.ServerLifecycleActionCreate + } + return domain.ServerLifecycleActionStart +} + func lifecycleJobProgress(deployment domain.ServerDeploymentDefinition) domain.JobProgress { if deployment.Mode != "" { return domain.JobProgress{Percent: 0, Phase: "queued", Message: "deployment queued; awaiting Run claim"} diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 1892687..8043f62 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -36,6 +36,7 @@ import type { CurrentUserResponse, DependencyCatalogResponse, DependencyJobRequest, + DeclaredFileReadSnapshotResponse, FileOperationDispatchRequest, FileOperationDispatchResponse, GameClientBridgeCancelRequest, @@ -627,6 +628,11 @@ export class PlatformApiClient { }); } + async getDeclaredFileReadSnapshot(serverInstanceId: string, fileKey: string): Promise { + const query = new URLSearchParams({ key: fileKey }); + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read-snapshot?${query.toString()}`); + } + async listLogStreams(): Promise { return this.request("/log-streams"); } diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index fa57d8e..ff9ef6a 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -1455,6 +1455,20 @@ export interface FileOperationDispatchResponse { job: JobResponse; } +export interface DeclaredFileReadSnapshotResponse { + serverInstanceId: string; + pluginId: string; + key: string; + state: "ready" | "pending" | "not-read" | string; + content?: string; + version?: number; + checksum?: string; + sizeBytes?: number; + jobId?: string; + readAt?: string; + reason?: string; +} + export interface LogStreamResponse { id: string; serverInstanceId: string; diff --git a/platform_web/contracts/pluginPageHost.ts b/platform_web/contracts/pluginPageHost.ts new file mode 100644 index 0000000..26a36ea --- /dev/null +++ b/platform_web/contracts/pluginPageHost.ts @@ -0,0 +1,25 @@ +export interface PluginPageFileRequestResult { + status: string; + message: string; + jobId?: string; +} + +export interface PluginPageFileReadSnapshot { + serverInstanceId: string; + pluginId: string; + key: string; + state: "ready" | "pending" | "not-read" | "unavailable" | string; + content?: string; + version?: number; + checksum?: string; + sizeBytes?: number; + jobId?: string; + readAt?: string; + reason?: string; +} + +export interface PluginPageWorkspaceActions { + requestFile?: (fileKey: string) => Promise; + getFileSnapshot?: (fileKey: string) => Promise; + writeFile?: (fileKey: string, content: string, options?: { expectedChecksum?: string }) => Promise; +} diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index 4eaff3f..bfeb1ff 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -326,6 +326,7 @@ describe("first-party console pages", () => { expect(serverLiveOperationsSource).toContain("mergeTerminalLines"); expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000"); expect(serverLiveOperationsSource).not.toContain("logStreamPollMs = 5000"); + expect(serverLiveOperationsSource).not.toContain("queryLogStream("); expect(serverLiveOperationsSource).not.toContain("SaveWorld"); }); diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index 573226f..a0ff362 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -95,4 +95,11 @@ describe("PluginPageHostPage", () => { expect(hostSource).toContain("loadPluginPageBundle"); expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/); }); + + it("keeps file workspace callbacks stable across parent operational refreshes", () => { + expect(hostSource).toContain("readyPluginRef.current = readyPlugin"); + expect(hostSource).toContain("hostContextRef.current = hostContext"); + expect(hostSource).toContain("}, [pluginId, serverId]);"); + expect(hostSource).not.toContain("}, [hostContext, readyPlugin, serverId]);"); + }); }); diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index 800d5fe..af3dc52 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -1,6 +1,6 @@ import { ArrowLeft, PlugZap } from "lucide-react"; import * as React from "react"; -import { useCallback, useEffect, useState, type ComponentType } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react"; import { platformApiClient } from "../api/client"; import type { GamePluginResponse } from "../api/types"; @@ -8,7 +8,8 @@ import { PageFrame } from "../components/PageFrame"; import { EmptyState, ErrorState, LoadingState } from "../components/StateViews"; import type { PageComponentProps } from "../contracts/page"; import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge"; -import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost"; +import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost"; +import { createPluginBridgeDispatcher, createPluginBridgeHostContext } from "../utils/pluginBridgeHost"; import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles"; type PluginPageState = @@ -18,16 +19,19 @@ type PluginPageState = interface PluginPageHostPageProps extends PageComponentProps { initialPlugin?: GamePluginResponse; + embedded?: boolean; } -export function PluginPageHostPage({ params, onNavigate, initialPlugin }: PluginPageHostPageProps) { +export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded = false }: PluginPageHostPageProps) { const pluginId = params.pluginId ?? ""; const routeKey = params.routeKey ?? ""; const serverId = params.serverId ?? ""; const [state, setState] = useState(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" }); - const [bundle, setBundle] = useState; workspace?: unknown; availability: PluginPageAvailability }> | null>(null); + const [bundle, setBundle] = useState; workspace?: unknown; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }> | null>(null); const [bundleError, setBundleError] = useState(""); const [availability, setAvailability] = useState({ available: false, reason: "正在验证 Companion 可用性。" }); + const readyPluginRef = useRef(undefined); + const hostContextRef = useRef | undefined>(undefined); const load = useCallback(async () => { if (!pluginId || !routeKey) { @@ -52,19 +56,107 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin const readyPlugin = state.status === "ready" ? state.plugin : undefined; const declaredPage = readyPlugin?.pages.find((candidate) => candidate.key === routeKey); - const declaredBundlePage = readyPlugin ? pluginBridgeManifestContractFromResponse(readyPlugin).pages.find((candidate) => candidate.key === routeKey) : undefined; + const manifestContract = useMemo(() => (readyPlugin ? pluginBridgeManifestContractFromResponse(readyPlugin) : undefined), [readyPlugin]); + const declaredBundlePage = manifestContract?.pages.find((candidate) => candidate.key === routeKey); + const hostContext = useMemo(() => manifestContract ? createPluginBridgeHostContext({ + plugin: manifestContract, + routeKey, + serverInstanceId: serverId || undefined, + themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" } + }) : undefined, [manifestContract, routeKey, serverId]); + readyPluginRef.current = readyPlugin; + hostContextRef.current = hostContext; + const workspaceActions = useMemo(() => { + if (!pluginId || !serverId) return undefined; + return { + requestFile: async (fileKey) => { + const plugin = readyPluginRef.current; + const context = hostContextRef.current; + if (!plugin || !context) { + return { status: "denied", message: "插件页面上下文尚未就绪。" }; + } + if (!plugin.fileWorkspace?.files.some((file) => file.key === fileKey)) { + return { status: "denied", message: "该文件不在当前插件声明的工作区内。" }; + } + const dispatch = createPluginBridgeDispatcher(context, platformApiClient); + const response = await dispatch({ + requestId: `web:plugin-file-read:${serverId}:${fileKey}:${Date.now()}`, + action: "files.request", + payload: { operation: "read", key: fileKey } + }); + const jobId = response.result?.jobId; + if (response.status === "queued" || response.status === "ok") { + return { status: response.status, jobId, message: jobId ? `读取任务 ${jobId} 已提交。` : "已提交文件读取请求。" }; + } + return { status: response.status, message: response.error?.message ?? "文件读取请求未能提交。" }; + }, + getFileSnapshot: async (fileKey) => { + const plugin = readyPluginRef.current; + if (!plugin) { + return { serverInstanceId: serverId, pluginId, key: fileKey, state: "unavailable", reason: "插件页面上下文尚未就绪。" }; + } + if (!plugin.fileWorkspace?.files.some((file) => file.key === fileKey)) { + return { serverInstanceId: serverId, pluginId: plugin.id, key: fileKey, state: "unavailable", reason: "该文件不在当前插件声明的工作区内。" }; + } + try { + return await platformApiClient.getDeclaredFileReadSnapshot(serverId, fileKey); + } catch (error) { + return { + serverInstanceId: serverId, + pluginId: plugin.id, + key: fileKey, + state: "unavailable", + reason: error instanceof Error ? error.message : "无法读取文件快照。" + }; + } + }, + writeFile: async (fileKey, content, options) => { + const plugin = readyPluginRef.current; + const context = hostContextRef.current; + if (!plugin || !context) { + return { status: "denied", message: "插件页面上下文尚未就绪。" }; + } + const file = plugin.fileWorkspace?.files.find((candidate) => candidate.key === fileKey); + if (!file) { + return { status: "denied", message: "该文件不在当前插件声明的工作区内。" }; + } + if (file.kind !== "config" || !file.editable) { + return { status: "denied", message: "该声明文件不允许通过配置工作台写入。" }; + } + if (!context.permissions.includes("server.files.write")) { + return { status: "denied", message: "当前页面没有声明文件写入权限。" }; + } + try { + const response = await platformApiClient.dispatchFileOperation({ + serverInstanceId: serverId, + pluginId: plugin.id, + operation: "write", + key: fileKey, + content, + expectedChecksum: options?.expectedChecksum, + idempotencyKey: `web:plugin-file-write:${serverId}:${fileKey}:${Date.now()}` + }); + return { status: response.status, jobId: response.job.id, message: `写入任务 ${response.job.id} 已提交。` }; + } catch (error) { + return { status: "error", message: error instanceof Error ? error.message : "文件写入请求未能提交。" }; + } + } + }; + }, [pluginId, serverId]); + const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : ""; + const loadableBundlePage = useMemo(() => declaredBundlePage, [bundleLoadKey]); useEffect(() => { let active = true; - if (!declaredBundlePage) return () => { active = false; }; + if (!loadableBundlePage) return () => { active = false; }; globalThis.__PLUGIN_PAGE_REACT__ = React; setBundle(null); setBundleError(""); - void loadPluginPageBundle(declaredBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); }); + void loadPluginPageBundle(loadableBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); }); if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; } void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => { if (active) setAvailability({ available: status.available, reason: status.reason, features: status.features }); }).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); }); return () => { active = false; }; - }, [declaredBundlePage, serverId]); + }, [bundleLoadKey, loadableBundlePage, serverId]); if (state.status === "loading") { return ; @@ -77,13 +169,18 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin if (!page) { return ; } - const manifestContract = pluginBridgeManifestContractFromResponse(state.plugin); - const hostContext = createPluginBridgeHostContext({ - plugin: manifestContract, - routeKey, - serverInstanceId: serverId || undefined, - themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" } - }); + if (!hostContext) { + return ; + } + if (embedded) { + return ( + <> + {bundleError && } + {!bundle && !bundleError && } + {bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, workspaceActions, availability })} + + ); + } return (
{bundleError && } {!bundle && !bundleError && } - {bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, availability })} + {bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, workspaceActions, availability })}
); } diff --git a/platform_web/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index 26d798a..e4b316b 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -170,6 +170,15 @@ describe("ServerDetailPage config write approval", () => { expect(serverDetailPageSource).not.toContain('capability: "process.install"'); }); + it("routes the SCUM logs section to the declared file management workbench", () => { + expect(serverDetailPageSource).toContain("serverDetailSectionLabel(entry, instance.data.pluginId)"); + expect(serverDetailPageSource).toContain('"文件管理"'); + expect(serverDetailPageSource).toContain("ScumFileManagementSection"); + expect(serverDetailPageSource).toContain("PluginPageHostPage"); + expect(serverDetailPageSource).toContain('routeKey: "files-config"'); + expect(serverDetailPageSource).toContain('file.kind === "config"'); + }); + it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => { expect(serverDetailPageSource).toContain("parsePluginArtifactReference(result)"); expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)"); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 4501b13..fa72b9a 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -20,7 +20,7 @@ import type { ServerMemberResponse, ServerMetricsResponse, RuntimeBindingResponse, - ServerDeploymentResponse, + ServerDeploymentResponse, ServerRuntimeActionsResponse, MetricSampleResponse, RemoteAdapterDeclarationResponse @@ -73,6 +73,7 @@ import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePlugi import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer"; import { cx } from "../utils/classes"; import { stateLabel, statusClass } from "./ServersPage"; +import { PluginPageHostPage } from "./PluginPageHostPage"; import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents"; type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; @@ -81,7 +82,8 @@ const defaultConfigKey = "server.properties"; const serverDetailRefreshMs = 5000; const serverMetricFreshMs = 30000; -export function ServerDetailPage({ session, params, operations, onNavigate }: PageComponentProps) { +export function ServerDetailPage(props: PageComponentProps) { + const { session, params, operations, onNavigate } = props; const serverId = params.serverId ?? ""; const [section, setSection] = useState("logs"); const [instance, setInstance] = useState>({ status: "loading" }); @@ -94,7 +96,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa const [remoteAdapters, setRemoteAdapters] = useState([]); const [runtimeActions, setRuntimeActions] = useState>({ status: "loading" }); const [runtimeBinding, setRuntimeBinding] = useState>({ status: "loading" }); - const [deployment, setDeployment] = useState>({ status: "loading" }); + const [deployment, setDeployment] = useState>({ status: "loading" }); const [liveLogOpen, setLiveLogOpen] = useState(false); const [terminalOpen, setTerminalOpen] = useState(false); const [confirm, setConfirm] = useState Promise }>(null); @@ -107,7 +109,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa } setInstance({ status: "loading" }); try { - const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([ + const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([ platformApiClient.getServerInstance(serverId), platformApiClient.listGamePlugins(), platformApiClient.listJobs(serverId), @@ -119,10 +121,10 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa .getServerRuntimeBinding(serverId) .then((data): LoadState => ({ status: "ready", data })) .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "运行配置加载失败" })), - platformApiClient - .getServerDeployment(serverId) - .then((data): LoadState => ({ status: "ready", data })) - .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })), + platformApiClient + .getServerDeployment(serverId) + .then((data): LoadState => ({ status: "ready", data })) + .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })), platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })), platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })), platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 })) @@ -132,7 +134,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa setJobs(jobResponse.items); setRuntimeActions(runtimeResponse); setRuntimeBinding(bindingResponse); - setDeployment(deploymentResponse); + setDeployment(deploymentResponse); setMetricHistory(metricHistoryResponse.items); setBackups(backupResponse.items); setRemoteAdapters(adapterResponse.items); @@ -150,7 +152,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa setArtifacts([]); setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" }); setRuntimeBinding({ status: "error", reason: "运行配置加载失败" }); - setDeployment({ status: "error", reason: "部署定义加载失败" }); + setDeployment({ status: "error", reason: "部署定义加载失败" }); setMetricHistory([]); setBackups([]); setRemoteAdapters([]); @@ -267,7 +269,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa aria-current={section === entry.id ? "page" : undefined} onClick={() => setSection(entry.id)} > - {entry.label} + {serverDetailSectionLabel(entry)} ))} @@ -330,12 +332,12 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa aria-current={section === entry.id ? "page" : undefined} onClick={() => setSection(entry.id)} > - {entry.label} + {serverDetailSectionLabel(entry, instance.data.pluginId)} ))} - {section === "logs" && } + {section === "logs" && (instance.data.pluginId === "game.scum" ? plugin.id === instance.data.pluginId)} /> : )} {section === "terminal" && } {section === "runtime" && ( ({ ...pageProps.params, pluginId: plugin?.id ?? "", routeKey: "files-config", serverId }), [pageProps.params.pluginId, pageProps.params.routeKey, pageProps.params.serverId, plugin?.id, serverId]); + if (!plugin) { + return ; + } + return ; +} + interface ServerMetadataSectionProps { instance: ServerInstanceResponse; session: PageComponentProps["session"]; @@ -1999,7 +2019,7 @@ function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifact requestId: `web:bridge:${serverId}:${plugin.id}:${action}:${Date.now()}`, action, aiPurpose: action === "ai.invoke" ? plugin.aiPurposes[0] : undefined, - payload: bridgePayloadForAction(action, serverInstance, artifacts[0]) + payload: bridgePayloadForAction(action, plugin, serverInstance, artifacts[0]) }); setPendingAction(null); if (response.status === "ok" || response.status === "queued") { @@ -2044,9 +2064,10 @@ function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifact ); } -function bridgePayloadForAction(action: PluginBridgeAction, serverInstance: ServerInstanceResponse, artifact?: ArtifactResponse): Record | undefined { +function bridgePayloadForAction(action: PluginBridgeAction, plugin: GamePluginResponse, serverInstance: ServerInstanceResponse, artifact?: ArtifactResponse): Record | undefined { if (action === "files.request") { - return { operation: "read", key: "logs/latest.log", expectedConfigVersion: String(serverInstance.configVersion) }; + const declaredFileKey = plugin.fileWorkspace?.files.find((file) => file.kind === "config")?.key ?? plugin.fileWorkspace?.files[0]?.key ?? "logs/latest.log"; + return { operation: "read", key: declaredFileKey, expectedConfigVersion: String(serverInstance.configVersion) }; } if (action === "artifacts.open" && artifact) { return { artifactId: artifact.id }; diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index 9a9a17e..7da7737 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -470,6 +470,9 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .section-tab{display:inline-flex;align-items:center;gap:6px;min-height:38px;padding:0 14px;border:1px solid var(--line-strong);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;white-space:nowrap} .section-tab:focus-visible,.section-tab:hover{border-color:var(--accent);outline:0} .section-tab-active{background:var(--primary-command-surface);border-color:var(--accent-deep);color:#fff;font-weight:700;box-shadow:0 10px 24px var(--candy-glow),inset 0 1px 0 var(--crystal-rim),0 0 18px var(--moonbeam)} +.file-workbench{display:grid;grid-template-columns:minmax(200px,.82fr) minmax(0,2fr);gap:12px;align-items:start;min-width:0}.file-workbench-nav{display:grid;gap:12px;min-width:0;padding-right:12px;border-right:1px solid var(--line)}.file-workbench-directory{display:grid;gap:4px;min-width:0}.file-workbench-directory-heading{display:grid;gap:2px;padding:0 8px 4px;color:var(--ink-soft);font-size:12px}.file-workbench-directory-heading strong{color:var(--ink);font-size:13px}.file-workbench-directory-heading span{font-family:var(--font-mono);overflow-wrap:anywhere}.file-workbench-file{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;width:100%;min-height:42px;align-items:center;padding:7px 8px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--ink-soft);font:inherit;text-align:left;cursor:pointer}.file-workbench-file strong{min-width:0;color:var(--ink);overflow-wrap:anywhere}.file-workbench-file span{font-size:11px;color:var(--ink-faint);white-space:nowrap}.file-workbench-file:hover,.file-workbench-file:focus-visible{border-color:var(--line-strong);background:var(--control-surface);outline:0}.file-workbench-file[aria-current=page]{border-color:var(--accent);background:var(--primary-command-surface);color:#fff}.file-workbench-file[aria-current=page] strong,.file-workbench-file[aria-current=page] span{color:#fff}.file-workbench-detail{display:grid;gap:12px;min-width:0}.file-workbench-detail>.panel-header{padding-bottom:10px;border-bottom:1px solid var(--line)}.file-workbench-mode{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding-bottom:10px;border-bottom:1px solid var(--line)}.file-workbench-mode>span{margin-right:auto;color:var(--ink-soft);font-size:12px}.file-workbench-mode button{min-height:32px;padding:0 10px;border:1px solid var(--line-strong);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);font:inherit;cursor:pointer}.file-workbench-mode button:hover,.file-workbench-mode button:focus-visible{border-color:var(--accent);outline:0}.file-workbench-mode .file-workbench-mode-active{border-color:var(--accent);background:var(--primary-command-surface);color:#fff}.file-workbench-fields{display:grid;gap:0}.file-workbench-field{display:grid;grid-template-columns:minmax(0,1fr) minmax(150px,.72fr);gap:8px 12px;align-items:center;padding:10px 0;border-bottom:1px solid color-mix(in srgb,var(--line) 74%,transparent)}.file-workbench-field>span{display:grid;gap:3px;min-width:0}.file-workbench-field strong{color:var(--ink);font-size:13px}.file-workbench-field small{color:var(--ink-faint);overflow-wrap:anywhere}.file-workbench-field input{min-width:0;min-height:34px;border:1px solid var(--line-strong);border-radius:6px;padding:0 9px;background:var(--surface-solid);color:var(--ink);font:inherit}.file-workbench-field input:read-only,.file-workbench-field input:disabled{color:var(--ink-soft);background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.file-workbench-field>small{grid-column:1/-1}.file-workbench-raw{display:grid;gap:8px;min-width:0}.file-workbench-raw .runtime-task-log{margin:0;max-height:560px;overflow:auto}.file-workbench-raw small{color:var(--ink-faint);font-family:var(--font-mono);overflow-wrap:anywhere} +.file-workbench-picker{display:grid;gap:6px;color:var(--ink-soft);font-size:12px;font-weight:700}.file-workbench-picker select,.file-workbench-field select{width:100%;min-height:36px;border:1px solid var(--line-strong);border-radius:7px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit}.file-workbench-picker select:focus,.file-workbench-field select:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}.file-workbench-number-control{display:grid;grid-template-columns:minmax(0,1fr) 92px;gap:8px;align-items:center}.file-workbench-number-control input[type=range]{padding:0;accent-color:var(--accent)}.file-workbench-raw-editor{width:100%;min-height:420px;resize:vertical;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--code-surface);color:var(--code-ink);font-family:var(--font-mono);font-size:12px;line-height:1.55}.file-workbench-raw-editor:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}.file-workbench-actions{justify-content:flex-start}.file-workbench-unknown,.file-workbench-diff{display:grid;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line)}.file-workbench-unknown h3{margin:0;color:var(--ink);font-size:13px}.file-workbench-diff .runtime-task-log{max-height:260px}.file-workbench-encoding{padding-top:0} +@media (max-width:760px){.file-workbench{grid-template-columns:1fr}.file-workbench-nav{padding:0 0 10px;border-right:0;border-bottom:1px solid var(--line)}.file-workbench-field{grid-template-columns:1fr}.file-workbench-mode>span{width:100%;margin-right:0}.file-workbench-mode button{flex:1 1 0}} .overview-two-col{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} .log-filter-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center} .log-filter-bar input,.log-filter-bar select{min-height:36px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit;font-size:13px} diff --git a/platform_web/utils/pluginPageBundles.ts b/platform_web/utils/pluginPageBundles.ts index afe0aa0..838a521 100644 --- a/platform_web/utils/pluginPageBundles.ts +++ b/platform_web/utils/pluginPageBundles.ts @@ -1,23 +1,24 @@ import type { ComponentType } from "react"; import type { PluginBridgeHostContext, PluginPageContract } from "../contracts/pluginBridge"; +import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost"; export interface PluginPageBundleModule { pluginPageBundle: { key: string; version: string; integritySha256: string }; - renderPluginPage: (react: { createElement: typeof import("react").createElement }, input: { page: PluginPageContract; context: PluginBridgeHostContext; workspace?: unknown; availability: PluginPageAvailability }) => ReturnType; + renderPluginPage: (react: typeof import("react"), input: { page: PluginPageContract; context: PluginBridgeHostContext; workspace?: unknown; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }) => ReturnType; } export type PluginPageAvailability = { available: boolean; reason?: string; features?: Array<{ key: string; available: boolean; reason?: string }> }; const pageBundles = import.meta.glob("../../plugins/examples/*/page-bundle/index.ts"); -export async function loadPluginPageBundle(page: PluginPageContract): Promise> { +export async function loadPluginPageBundle(page: PluginPageContract): Promise> { if (!page.bundleKey || !page.bundleVersion || !page.bundleIntegritySha256) throw new Error("插件没有声明受验证的页面 bundle。"); const match = Object.entries(pageBundles).find(([path]) => path.endsWith(`/${page.bundleKey}/page-bundle/index.ts`)); if (!match) throw new Error("已声明的插件页面 bundle 未安装。"); const module = await match[1](); if (module.pluginPageBundle.key !== page.bundleKey || module.pluginPageBundle.version !== page.bundleVersion || module.pluginPageBundle.integritySha256 !== page.bundleIntegritySha256) throw new Error("插件页面 bundle 完整性或版本校验失败。"); - return ({ context, workspace, availability }) => module.renderPluginPage({ createElement: (awaitReact()).createElement }, { page, context, workspace, availability }); + return ({ context, workspace, workspaceActions, availability }) => module.renderPluginPage(awaitReact(), { page, context, workspace, workspaceActions, availability }); } function awaitReact(): typeof import("react") { diff --git a/plugins/examples/scum-server-plugin/bin/scum-start.cmd b/plugins/examples/scum-server-plugin/bin/scum-start.cmd index ea9e37c..9a10ff5 100755 --- a/plugins/examples/scum-server-plugin/bin/scum-start.cmd +++ b/plugins/examples/scum-server-plugin/bin/scum-start.cmd @@ -10,22 +10,44 @@ if "%SERVER_CREATE_GAMEPORT%"=="" set "SERVER_CREATE_GAMEPORT=7779" if "%SERVER_CREATE_MAXPLAYERS%"=="" set "SERVER_CREATE_MAXPLAYERS=128" if "%SERVER_LOG_FLAG%"=="" set "SERVER_LOG_FLAG=-log" +set "SCUM_INSTALL_UPDATE=%~dp0scum-install-update.cmd" set "SCUM_EXE_MARKER=%SERVER_ROOT_WINDOWS%\.scum-exe-path" if exist "%SCUM_EXE_MARKER%" set /p SCUM_EXE=<"%SCUM_EXE_MARKER%" if "%SCUM_EXE%"=="" set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%" if not exist "%SCUM_EXE%" call :resolve_scum_exe -if not exist "%SCUM_EXE%" exit /b 2 +set "SCUM_NEEDS_INSTALL=0" +if not exist "%SCUM_EXE%" set "SCUM_NEEDS_INSTALL=1" +if not exist "%SCUM_EXE%" ( + echo [scum-start] SCUM executable was not found. Running plugin install/update script. + call :install_or_update +) +if "%SCUM_NEEDS_INSTALL%"=="1" if errorlevel 1 exit /b %ERRORLEVEL% +if "%SCUM_NEEDS_INSTALL%"=="1" call :resolve_scum_exe +if not exist "%SCUM_EXE%" ( + echo [scum-start] SCUM executable is still missing after install/update. + exit /b 2 +) +>"%SCUM_EXE_MARKER%" echo(%SCUM_EXE% for %%I in ("%SCUM_EXE%") do set "SCUM_EXE_DIR=%%~dpI" for %%I in ("%SCUM_EXE_DIR%..\..\..") do set "SCUM_WORKDIR=%%~fI" if not exist "%SCUM_WORKDIR%" set "SCUM_WORKDIR=%SERVER_ROOT_WINDOWS%" pushd "%SCUM_WORKDIR%" +echo [scum-start] Starting "%SCUM_EXE%" -port=%SERVER_CREATE_GAMEPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG% "%SCUM_EXE%" -port=%SERVER_CREATE_GAMEPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG% set "SCUM_START_RESULT=%ERRORLEVEL%" popd exit /b %SCUM_START_RESULT% +:install_or_update +if not exist "%SCUM_INSTALL_UPDATE%" ( + echo [scum-start] Missing plugin install/update script: %SCUM_INSTALL_UPDATE% + exit /b 3 +) +call "%SCUM_INSTALL_UPDATE%" +exit /b %ERRORLEVEL% + :resolve_scum_exe set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%" if exist "%SCUM_EXE%" exit /b 0 diff --git a/plugins/examples/scum-server-plugin/features/contracts.ts b/plugins/examples/scum-server-plugin/features/contracts.ts index 26d5850..f8ac608 100644 --- a/plugins/examples/scum-server-plugin/features/contracts.ts +++ b/plugins/examples/scum-server-plugin/features/contracts.ts @@ -9,10 +9,12 @@ export type SCUMFeatureMigrationStatus = { authority: "plugin" | "transitional-r export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string; audit?: Record }; export type SCUMVehicleSpawn = { vehicleCode: string }; export type SCUMVehicleSpawnOption = { code: string; label: string }; +export type SCUMLogicalDirectory = { key: string; label: string; scope: "config" | "logs" }; +export type SCUMLogicalFile = { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; editable?: boolean }; export type SCUMConfigField = { key: string; label: string; description: string; control: "text" | "number" | "port" | "boolean"; - configKey: string; defaultValue: string; restartImpact: "restart-required" | "none"; minimum?: number; maximum?: number; + fileKey: string; configKey: string; defaultValue: string; restartImpact: "restart-required" | "none"; minimum?: number; maximum?: number; }; export type SCUMConfigRead = { fields: Record; observedAt: string }; export type SCUMConfigPatch = { changes: Array<{ key: string; value: string }>; reason: string; idempotencyKey: string }; @@ -34,4 +36,4 @@ export type SCUMTrajectoryPoint = { occurredAt: string; subjectId: string; subje export type SCUMTrajectory = { subjectId: string; subjectType: "player" | "vehicle"; points: SCUMTrajectoryPoint[]; provenance: SCUMMigrationProvenance }; export type SCUMTrajectoryCollection = { available: boolean; reason?: string; trajectories: SCUMTrajectory[] }; -export type SCUMFeatureWorkspace = { configFields?: SCUMConfigField[]; map?: { mapId: string; mapVersion: string; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number } }; +export type SCUMFeatureWorkspace = { defaultDirectoryKey?: string; directories?: SCUMLogicalDirectory[]; files?: SCUMLogicalFile[]; configFields?: SCUMConfigField[]; map?: { mapId: string; mapVersion: string; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number } }; diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 680ae8d..ee69550 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -1,28 +1,432 @@ -import { configurationCatalog, stateFieldCatalog, vehicleSpawnCatalog } from "./schemas.js"; -import type { SCUMFeatureWorkspace } from "./contracts.js"; +import { configurationCatalog } from "./schemas.js"; +import type { SCUMConfigField, SCUMFeatureWorkspace, SCUMLogicalDirectory, SCUMLogicalFile } from "./contracts.js"; -export type ReactLike = { createElement: (...args: any[]) => any; useMemo?: (factory: () => T, deps: readonly unknown[]) => T }; -export type SCUMPageContext = { serverInstanceId?: string; permissions: string[]; availability: { available: boolean; reason?: string }; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; workspace?: SCUMFeatureWorkspace }; +type StateSetter = (next: T | ((previous: T) => T)) => void; +export type ReactLike = { + createElement: (...args: any[]) => any; + useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void; + useState?: (initialState: T | (() => T)) => [T, StateSetter]; +}; + +export type SCUMFileReadSnapshot = { + serverInstanceId: string; + pluginId: string; + key: string; + state: "ready" | "pending" | "not-read" | "unavailable" | string; + content?: string; + version?: number; + checksum?: string; + sizeBytes?: number; + jobId?: string; + readAt?: string; + reason?: string; +}; + +export type SCUMPageContext = { + serverInstanceId?: string; + permissions: string[]; + availability: { available: boolean; reason?: string }; + featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; + workspace?: SCUMFeatureWorkspace; + workspaceActions?: { + requestFile?: (fileKey: string) => Promise<{ status: string; message: string; jobId?: string }>; + getFileSnapshot?: (fileKey: string) => Promise; + writeFile?: (fileKey: string, content: string, options?: { expectedChecksum?: string }) => Promise<{ status: string; message: string; jobId?: string }>; + }; +}; + +type NormalizedWorkspace = { defaultDirectoryKey: string; directories: readonly SCUMLogicalDirectory[]; files: readonly SCUMLogicalFile[]; configFields: readonly SCUMConfigField[] }; +type ConfigMode = "fields" | "source"; +type RawEncoding = "utf-8" | "utf-16le"; +type FileRequestState = { fileKey: string; status: string; message: string; jobId?: string } | null; +type PreviewState = { fileKey: string; mode: ConfigMode; summary: string; proposedContent: string; lines: readonly DiffLine[] } | null; +type DiffLine = { kind: "same" | "added" | "removed"; text: string }; + +const fallbackDirectories: readonly SCUMLogicalDirectory[] = [ + { key: "scum-config", label: "服务器配置", scope: "config" }, + { key: "scum-logs", label: "日志文件", scope: "logs" } +]; +const fallbackFiles: readonly SCUMLogicalFile[] = [ + { key: "scum-server-settings", directoryKey: "scum-config", label: "ServerSettings.ini", kind: "config", editable: true }, + { key: "scum-game-config", directoryKey: "scum-config", label: "Game.ini", kind: "config" }, + { key: "scum-engine-config", directoryKey: "scum-config", label: "Engine.ini", kind: "config" }, + { key: "scum-game-user-settings", directoryKey: "scum-config", label: "GameUserSettings.ini", kind: "config" }, + { key: "scum-admin-log", directoryKey: "scum-logs", label: "Admin.log", kind: "log", streamKey: "scum.admin" }, + { key: "scum-chat-log", directoryKey: "scum-logs", label: "Chat.log", kind: "log", streamKey: "scum.chat" }, + { key: "scum-kill-log", directoryKey: "scum-logs", label: "Kill.log", kind: "log", streamKey: "scum.kill" }, + { key: "scum-login-log", directoryKey: "scum-logs", label: "Login.log", kind: "log", streamKey: "scum.login" }, + { key: "scum-server-log", directoryKey: "scum-logs", label: "Server.log", kind: "log", streamKey: "scum.server" } +]; +const directoryRelativePaths: Record = { "scum-config": "/SCUM/Saved/Config/WindowsServer", "scum-logs": "/SCUM/Saved/SaveFiles/Logs" }; export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) { - const e = react.createElement; const fields = input.workspace?.configFields?.length ? input.workspace.configFields : configurationCatalog; const vehicleCodes = vehicleSpawnCatalog; const scoped = Boolean(input.serverInstanceId); const canRead = scoped && input.permissions.includes("server.game-client.read"); const canCommand = scoped && input.permissions.includes("server.game-client.command"); const canMaintain = scoped && input.permissions.includes("server.game-client.maintenance"); - return e("div", { className: "console-page", "aria-label": "SCUM 插件功能页面" }, - e("section", { className: "console-panel" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "SCUM 插件运维"), e("p", { className: "provider-id" }, "SCUM 语义、界面和适配器由插件提供;平台仅提供已授权的服务器隔离宿主。")), e("span", { className: "page-status" }, availabilityText(input.availability, scoped))), - e("div", { className: "console-row-list" }, e("div", { className: "console-row" }, e("strong", null, "绑定服务器"), e("span", null, input.serverInstanceId ?? "未绑定")), e("div", { className: "console-row" }, e("strong", null, "运行时 schema"), e("span", null, "按受限通道探测")), e("div", { className: "console-row" }, e("strong", null, "宿主权限"), e("span", null, input.permissions.join("、") || "无")))), - configurationPanel(e, fields, canRead, canMaintain, featureAvailability(input, "config.manage")), - playerPanel(e, canRead, featureAvailability(input, "player.intelligence")), - rewardPanel(e, canRead, canCommand, featureAvailability(input, "reward.delivery")), - statePanel(e, canRead, canMaintain, featureAvailability(input, "state.patch")), - vehicleSpawnPanel(e, vehicleCodes, canCommand, featureAvailability(input, "vehicle.spawn")), - trajectoryPanel(e, canRead, featureAvailability(input, "trajectory.collect")) + const e = react.createElement; + const workspace = normalizeWorkspace(input.workspace); + const [selectedDirectoryKey, setSelectedDirectoryKey] = usePluginState(react, workspace.defaultDirectoryKey); + const effectiveDirectoryKey = workspace.directories.some((directory) => directory.key === selectedDirectoryKey) ? selectedDirectoryKey : workspace.defaultDirectoryKey; + const directoryFiles = workspace.files.filter((file) => file.directoryKey === effectiveDirectoryKey); + const defaultFileKey = initialFileKey(workspace, effectiveDirectoryKey); + const [selectedFileKey, setSelectedFileKey] = usePluginState(react, defaultFileKey); + const selectedFile = directoryFiles.find((file) => file.key === selectedFileKey) ?? directoryFiles[0] ?? workspace.files[0]; + const selectedFields = selectedFile ? workspace.configFields.filter((field) => field.fileKey === selectedFile.key) : []; + const [configMode, setConfigMode] = usePluginState(react, "fields"); + const [fieldDraft, setFieldDraft] = usePluginState>(react, {}); + const [rawDraft, setRawDraft] = usePluginState>(react, {}); + const [rawEncoding, setRawEncoding] = usePluginState(react, "utf-8"); + const [snapshot, setSnapshot] = usePluginState(react, null); + const [requestState, setRequestState] = usePluginState(react, null); + const [writeState, setWriteState] = usePluginState(react, null); + const [preview, setPreview] = usePluginState(react, null); + const scoped = Boolean(input.serverInstanceId); + const canFilesRead = scoped && input.permissions.includes("server.files.read"); + const canFilesWrite = scoped && input.permissions.includes("server.files.write"); + + if (react.useEffect) { + react.useEffect(() => { + let active = true; + setPreview(null); + setWriteState(null); + if (!selectedFile || !input.workspaceActions?.getFileSnapshot) { + setSnapshot(null); + return () => { active = false; }; + } + void input.workspaceActions.getFileSnapshot(selectedFile.key).then((next) => { + if (active) setSnapshot(next); + }).catch((error) => { + if (active) setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: selectedFile.key, state: "unavailable", reason: error instanceof Error ? error.message : "无法读取文件快照。" }); + }); + return () => { active = false; }; + }, [input.serverInstanceId, input.workspaceActions?.getFileSnapshot, selectedFile?.key]); + } + + function selectDirectory(directoryKey: string) { + const nextFiles = workspace.files.filter((file) => file.directoryKey === directoryKey); + const nextFile = nextFiles.find((file) => file.kind === "config") ?? nextFiles[0]; + setSelectedDirectoryKey(directoryKey); + if (nextFile) selectFile(nextFile, directoryKey); + } + + function selectFile(file: SCUMLogicalFile, directoryKey = file.directoryKey) { + setSelectedDirectoryKey(directoryKey); + setSelectedFileKey(file.key); + setConfigMode(file.kind === "config" && workspace.configFields.some((field) => field.fileKey === file.key) ? "fields" : "source"); + setRequestState(null); + setWriteState(null); + setPreview(null); + setRawEncoding("utf-8"); + } + + function requestSelectedFile() { + if (!selectedFile || !canFilesRead || !input.workspaceActions?.requestFile) return; + setRequestState({ fileKey: selectedFile.key, status: "pending", message: "正在提交文件读取请求..." }); + setPreview(null); + void input.workspaceActions.requestFile(selectedFile.key).then((result) => { + setRequestState({ fileKey: selectedFile.key, status: result.status, message: result.message, jobId: result.jobId }); + if (result.status === "queued" || result.status === "ok") { + setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: selectedFile.key, state: "pending", jobId: result.jobId, reason: "等待运行端完成文件读取。" }); + } + }).catch((error) => setRequestState({ fileKey: selectedFile.key, status: "error", message: error instanceof Error ? error.message : "文件读取请求失败。" })); + } + + function refreshSelectedSnapshot() { + if (!selectedFile || !input.workspaceActions?.getFileSnapshot) return; + setPreview(null); + void input.workspaceActions.getFileSnapshot(selectedFile.key).then(setSnapshot).catch((error) => { + setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: selectedFile.key, state: "unavailable", reason: error instanceof Error ? error.message : "无法读取文件快照。" }); + }); + } + + function previewModeChange(mode: ConfigMode, content: string, current: string) { + if (!selectedFile) return; + const diff = buildSimpleDiff(current, content); + setPreview({ fileKey: selectedFile.key, mode, proposedContent: content, summary: diff.summary, lines: diff.lines }); + } + + function writePreviewedContent() { + if (!selectedFile || !preview || preview.fileKey !== selectedFile.key || !input.workspaceActions?.writeFile) return; + setWriteState({ fileKey: selectedFile.key, status: "pending", message: "正在提交声明文件写入..." }); + void input.workspaceActions.writeFile(selectedFile.key, preview.proposedContent, { expectedChecksum: snapshot?.checksum }).then((result) => { + setWriteState({ fileKey: selectedFile.key, status: result.status, message: result.message, jobId: result.jobId }); + setPreview(null); + }).catch((error) => setWriteState({ fileKey: selectedFile.key, status: "error", message: error instanceof Error ? error.message : "文件写入请求失败。" })); + } + + return e("section", { className: "console-panel", "aria-label": "SCUM 文件管理" }, + e("div", { className: "panel-header" }, + e("div", null, e("h2", null, "文件管理"), e("p", { className: "provider-id" }, "第一级选择目录,第二级选择目录内文件;配置默认表单,日志只读原文。")), + e("span", { className: "page-status" }, scoped ? "声明文件工作区" : "插件页面未绑定服务器") + ), + e("div", { className: "file-workbench" }, + navigationPane(e, workspace, effectiveDirectoryKey, directoryFiles, selectedFile?.key, selectDirectory, selectFile), + selectedFile + ? fileDetail(e, { + file: selectedFile, + fields: selectedFields, + mode: selectedFile.kind === "log" ? "source" : configMode, + setMode: setConfigMode, + fieldDraft, + setFieldDraft, + rawDraft, + setRawDraft, + rawEncoding, + setRawEncoding, + snapshot: snapshot?.key === selectedFile.key ? snapshot : null, + requestState: requestState?.fileKey === selectedFile.key ? requestState : null, + writeState: writeState?.fileKey === selectedFile.key ? writeState : null, + preview: preview?.fileKey === selectedFile.key ? preview : null, + canFilesRead, + canFilesWrite, + canRequestFile: Boolean(input.workspaceActions?.requestFile), + canRefreshSnapshot: Boolean(input.workspaceActions?.getFileSnapshot), + canWriteFile: Boolean(input.workspaceActions?.writeFile), + onRequestFile: requestSelectedFile, + onRefreshSnapshot: refreshSelectedSnapshot, + onPreview: previewModeChange, + onWrite: writePreviewedContent + }) + : e("div", { className: "file-workbench-detail" }, e("p", { className: "page-status" }, "当前插件没有可展示的声明文件。")) + ) ); } -function configurationPanel(e: ReactLike["createElement"], fields: readonly { key: string; label: string; description: string; control: string; restartImpact: string }[], canRead: boolean, canMaintain: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 配置工作台" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "运行时配置字段目录"), e("p", { className: "provider-id" }, "每项修改先生成可审查差异,再由受控 Companion 执行。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "读取配置")), e("div", { className: "console-record-list" }, fields.map((field) => e("div", { className: "console-record", key: field.key }, e("strong", null, field.label), e("span", null, `${field.description} · ${field.control}`), e("small", null, field.restartImpact === "restart-required" ? "修改后需要受控重启" : "可在安全窗口内生效")))), e("p", { className: "page-status" }, canMaintain ? "配置写入仅在审批与处理器可用时开放。" : "当前服务器上下文没有配置维护权限。")); } -function playerPanel(e: ReactLike["createElement"], canRead: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 玩家档案" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "玩家、登录与风险信号"), e("p", { className: "provider-id" }, "只展示 Companion 已验证的语义事件;网络关联是按服务器不可逆计算,不上传原始网络值。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "查询玩家")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有玩家读取权限。" : availability.available ? "等待已验证的登录或登出事件。" : availability.reason ?? "没有兼容的事件生产者。")); } -function rewardPanel(e: ReactLike["createElement"], canRead: boolean, canCommand: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 礼物与通知" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "冻结礼物版本与通知"), e("p", { className: "provider-id" }, "物品投递与通知分离;未知投递结果不会自动重试。")), e("button", { type: "button", className: "icon-command", disabled: !canCommand || !availability.available }, "申请投递")), e("p", { className: "page-status" }, !canRead ? "当前服务器上下文没有礼物读取权限。" : !canCommand ? "当前服务器上下文没有受控投递权限。" : availability.reason ?? "需要已冻结 revision、已验证玩家身份和兼容处理器。")); } -function statePanel(e: ReactLike["createElement"], canRead: boolean, canMaintain: boolean, availability: { available: boolean; reason?: string }) { const fields = stateFieldCatalog; return e("section", { className: "console-panel", "aria-label": "SCUM 受控状态修改" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "受控属性修改"), e("p", { className: "provider-id" }, "仅列出运行时探测且在字段白名单中的字段,执行时要求预读、安全窗口与读后确认。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !canMaintain || !availability.available }, "创建修改申请")), e("div", { className: "console-row-list" }, fields.length ? fields.map((field) => e("div", { className: "console-row", key: field.key }, e("strong", null, field.label), e("span", null, `${field.minimum}–${field.maximum}`))) : e("p", { className: "page-status" }, "当前运行时没有已验证的状态字段。")), e("p", { className: "page-status" }, canMaintain ? availability.reason ?? "等待安全窗口验证。" : "当前服务器上下文没有维护权限。")); } -function vehicleSpawnPanel(e: ReactLike["createElement"], vehicles: readonly { code: string; label: string }[], canCommand: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 受限载具生成" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "受限载具生成"), e("p", { className: "provider-id" }, "仅可选择受控目录中的载具;不会显示或接收原始指令、参数或回包。")), e("button", { type: "button", className: "icon-command", disabled: !canCommand || !availability.available }, "生成载具")), e("div", { className: "console-row-list" }, vehicles.map((vehicle) => e("div", { className: "console-row", key: vehicle.code }, e("strong", null, vehicle.label), e("span", null, vehicle.code)))), e("p", { className: "page-status" }, !canCommand ? "当前服务器上下文没有受控指令权限。" : availability.available ? "仅在审批和 Companion 处理器均可用时开放。" : availability.reason ?? "当前没有已验证的载具生成处理器。")); } -function trajectoryPanel(e: ReactLike["createElement"], canRead: boolean, availability: { available: boolean; reason?: string }) { return e("section", { className: "console-panel", "aria-label": "SCUM 地图轨迹" }, e("div", { className: "panel-header" }, e("div", null, e("h2", null, "玩家与载具轨迹"), e("p", { className: "provider-id" }, "仅接受插件声明的服务器侧位置与上下车事件源;绝不使用 OCR、截图或桌面自动化。")), e("button", { type: "button", className: "icon-command", disabled: !canRead || !availability.available }, "读取轨迹")), e("p", { className: "page-status" }, canRead ? availability.reason ?? "当合法位置源可用时展示采样轨迹。" : "当前服务器上下文没有轨迹读取权限。")); } -function featureAvailability(input: SCUMPageContext, key: string): { available: boolean; reason?: string } { const feature = input.featureAvailability?.find((item) => item.key === key); return feature ?? { available: false, reason: "当前服务器没有已验证的 Companion 处理器或事件生产者。" }; } -function availabilityText(availability: { available: boolean; reason?: string }, scoped: boolean): string { if (!scoped) return "不可用:插件页面必须绑定服务器。"; return availability.available ? "已声明且已由 Companion 验证" : `不可用:${availability.reason ?? "没有可用的 Companion 处理器或事件生产者"}`; } +function navigationPane(e: ReactLike["createElement"], workspace: NormalizedWorkspace, directoryKey: string, directoryFiles: readonly SCUMLogicalFile[], selectedFileKey: string | undefined, onDirectoryChange: (directoryKey: string) => void, onFileSelect: (file: SCUMLogicalFile) => void) { + const activeDirectory = workspace.directories.find((directory) => directory.key === directoryKey); + const selectedFile = directoryFiles.find((file) => file.key === selectedFileKey) ?? directoryFiles[0]; + return e("aside", { className: "file-workbench-nav", "aria-label": "SCUM 文件两级菜单" }, + e("label", { className: "file-workbench-picker" }, + e("span", null, "目录"), + e("select", { value: directoryKey, onChange: (event: { target: { value: string } }) => onDirectoryChange(event.target.value), "aria-label": "选择目录" }, + workspace.directories.map((directory) => e("option", { key: directory.key, value: directory.key }, directoryRelativePath(directory.key) ?? directory.label)) + ) + ), + e("label", { className: "file-workbench-picker" }, + e("span", null, "文件"), + e("select", { value: selectedFile?.key ?? "", onChange: (event: { target: { value: string } }) => { const file = directoryFiles.find((candidate) => candidate.key === event.target.value); if (file) onFileSelect(file); }, "aria-label": "选择文件" }, + directoryFiles.length ? directoryFiles.map((file) => e("option", { key: file.key, value: file.key }, file.label)) : e("option", { value: "" }, "没有已声明文件") + ) + ), + e("div", { className: "file-workbench-directory-heading" }, + e("strong", null, activeDirectory?.label ?? "插件声明目录"), + e("span", null, directoryRelativePath(directoryKey) ?? "插件逻辑目录"), + e("small", null, `${directoryFiles.length} 个声明文件`) + ) + ); +} + +function fileDetail(e: ReactLike["createElement"], props: { + file: SCUMLogicalFile; fields: readonly SCUMConfigField[]; mode: ConfigMode; setMode: StateSetter; fieldDraft: Record; setFieldDraft: StateSetter>; + rawDraft: Record; setRawDraft: StateSetter>; rawEncoding: RawEncoding; setRawEncoding: StateSetter; snapshot: SCUMFileReadSnapshot | null; + requestState: FileRequestState; writeState: FileRequestState; preview: PreviewState; canFilesRead: boolean; canFilesWrite: boolean; canRequestFile: boolean; canRefreshSnapshot: boolean; canWriteFile: boolean; + onRequestFile: () => void; onRefreshSnapshot: () => void; onPreview: (mode: ConfigMode, content: string, current: string) => void; onWrite: () => void; +}) { + const canShowFields = props.file.kind === "config" && props.fields.length > 0; + const currentContent = props.snapshot?.state === "ready" ? decodeRawContent(props.snapshot.content ?? "", props.rawEncoding) : ""; + const snapshotValues = props.snapshot?.state === "ready" ? parseIniAssignments(currentContent) : {}; + return e("article", { className: "file-workbench-detail", "aria-label": `文件 ${props.file.label}` }, + e("div", { className: "panel-header" }, + e("div", null, e("h2", null, props.file.label), e("p", { className: "provider-id" }, `${directoryRelativePath(props.file.directoryKey) ?? "插件声明目录"} · ${props.file.kind === "config" ? "配置文件" : "日志文件"}`)), + e("div", { className: "console-row-actions" }, + e("button", { type: "button", className: "icon-command", disabled: !props.canFilesRead || !props.canRequestFile, onClick: props.onRequestFile }, "读取文件"), + e("button", { type: "button", className: "icon-command", disabled: !props.canRefreshSnapshot, onClick: props.onRefreshSnapshot }, "刷新结果") + ) + ), + props.requestState ? e("p", { className: "page-status", "data-state": props.requestState.status }, props.requestState.message) : null, + props.writeState ? e("p", { className: "page-status", "data-state": props.writeState.status }, props.writeState.message) : null, + props.file.kind === "log" || props.snapshot?.state === "ready" ? encodingSwitcher(e, props.rawEncoding, props.setRawEncoding) : null, + canShowFields + ? e("div", { className: "file-workbench-mode", role: "tablist", "aria-label": "表单配置与原文模式" }, + e("span", null, "编辑视图"), + e("button", { type: "button", role: "tab", "aria-selected": props.mode === "fields", className: props.mode === "fields" ? "file-workbench-mode-active" : undefined, onClick: () => props.setMode("fields") }, "配置表单"), + e("button", { type: "button", role: "tab", "aria-selected": props.mode === "source", className: props.mode === "source" ? "file-workbench-mode-active" : undefined, onClick: () => props.setMode("source") }, "原文模式") + ) + : null, + canShowFields && props.mode === "fields" + ? modeledConfigurationFields(e, props.file, props.fields, props.fieldDraft, props.setFieldDraft, snapshotValues, currentContent, props.snapshot?.state === "ready", props.canFilesWrite, props.canWriteFile, props.preview, props.onPreview, props.onWrite) + : rawFileView(e, props.file, currentContent, props.rawDraft, props.setRawDraft, props.snapshot, props.canFilesWrite, props.canWriteFile, props.preview, props.onPreview, props.onWrite) + ); +} + +function encodingSwitcher(e: ReactLike["createElement"], encoding: RawEncoding, setEncoding: StateSetter) { + return e("div", { className: "file-workbench-mode file-workbench-encoding", role: "tablist", "aria-label": "原文编码" }, + e("span", null, "文本编码"), + e("button", { type: "button", role: "tab", "aria-selected": encoding === "utf-8", className: encoding === "utf-8" ? "file-workbench-mode-active" : undefined, onClick: () => setEncoding("utf-8") }, "UTF-8"), + e("button", { type: "button", role: "tab", "aria-selected": encoding === "utf-16le", className: encoding === "utf-16le" ? "file-workbench-mode-active" : undefined, onClick: () => setEncoding("utf-16le") }, "UTF-16 LE") + ); +} + +function modeledConfigurationFields(e: ReactLike["createElement"], file: SCUMLogicalFile, fields: readonly SCUMConfigField[], draft: Record, setDraft: StateSetter>, values: Record, currentContent: string, hasReadSnapshot: boolean, canFilesWrite: boolean, canWriteFile: boolean, preview: PreviewState, onPreview: (mode: ConfigMode, content: string, current: string) => void, onWrite: () => void) { + const editable = hasReadSnapshot && canFilesWrite && file.editable !== false; + const fieldKeys = new Set(fields.map((field) => field.key)); + const selectedDraft = Object.fromEntries(Object.entries(draft).filter(([key]) => fieldKeys.has(key))); + const hasChanges = Object.keys(selectedDraft).length > 0; + const unknown = unknownIniAssignments(currentContent, fields); + return e("div", { className: "file-workbench-fields" }, + fields.map((field) => { + const value = draft[field.key] ?? values[field.configKey] ?? field.defaultValue; + return e("label", { className: "file-workbench-field", key: field.key }, + e("span", null, e("strong", null, field.label), e("small", null, `${field.configKey} · ${field.description}`)), + fieldControl(e, field, value, editable, (next) => setDraft((current) => ({ ...current, [field.key]: next }))), + e("small", null, `${controlLabel(field)} · ${field.restartImpact === "restart-required" ? "修改后需要受控重启" : "可在安全窗口内生效"}`) + ); + }), + unknown.length ? e("section", { className: "file-workbench-unknown", "aria-label": "未建模配置项" }, e("h3", null, "未建模配置项"), unknown.map((item) => e("div", { className: "console-row", key: `${item.key}:${item.index}` }, e("span", null, item.key), e("strong", null, item.value || "空值")))) : null, + e("div", { className: "console-row-actions file-workbench-actions" }, + e("button", { type: "button", className: "icon-command", disabled: !editable || !hasChanges, onClick: () => onPreview("fields", composeIniContent(currentContent, fields, selectedDraft), currentContent) }, "预览改动"), + e("button", { type: "button", className: "primary-command", disabled: !editable || !canWriteFile || !preview || preview.mode !== "fields", onClick: onWrite }, "提交写入") + ), + preview && preview.mode === "fields" ? diffPreview(e, preview) : null, + e("p", { className: "page-status" }, hasReadSnapshot ? editable ? "配置表单会保留原文中的未知行;提交前先预览差异。" : "当前账号仅有读取权限,配置项为只读。" : "先读取文件后才显示服务器当前值;未读取时不会把默认值伪装成原文。") + ); +} + +function fieldControl(e: ReactLike["createElement"], field: SCUMConfigField, value: string, editable: boolean, onChange: (next: string) => void) { + if (field.control === "boolean") { + return e("select", { value: normalizeBoolean(value) ? "true" : "false", disabled: !editable, "aria-label": field.label, onChange: (event: { target: { value: string } }) => onChange(event.target.value) }, + e("option", { value: "true" }, "是"), + e("option", { value: "false" }, "否") + ); + } + if (field.control === "number" || field.control === "port") { + return e("div", { className: "file-workbench-number-control" }, + e("input", { type: "range", value, min: field.minimum, max: field.maximum, disabled: !editable, "aria-label": `${field.label}滑动输入`, onChange: (event: { target: { value: string } }) => onChange(event.target.value) }), + e("input", { type: "number", value, min: field.minimum, max: field.maximum, readOnly: !editable, "aria-label": field.label, onChange: (event: { target: { value: string } }) => onChange(event.target.value) }) + ); + } + return e("input", { type: "text", value, readOnly: !editable, "aria-label": field.label, onChange: (event: { target: { value: string } }) => onChange(event.target.value) }); +} + +function rawFileView(e: ReactLike["createElement"], file: SCUMLogicalFile, currentContent: string, rawDraft: Record, setRawDraft: StateSetter>, snapshot: SCUMFileReadSnapshot | null, canFilesWrite: boolean, canWriteFile: boolean, preview: PreviewState, onPreview: (mode: ConfigMode, content: string, current: string) => void, onWrite: () => void) { + if (snapshot?.state !== "ready") { + const message = snapshot?.reason ?? (file.kind === "config" ? "尚未读取此配置文件的受控原文。" : "尚未读取此日志文件的受控内容。"); + return e("div", { className: "file-workbench-raw" }, e("p", { className: "page-status" }, message), snapshot?.jobId ? e("small", null, `读取任务 ${snapshot.jobId}`) : null); + } + const rawValue = rawDraft[file.key] ?? currentContent; + const editable = file.kind === "config" && canFilesWrite && file.editable !== false; + if (file.kind === "log") { + return e("div", { className: "file-workbench-raw" }, e("pre", { className: "runtime-task-log" }, rawValue), e("p", { className: "page-status" }, `日志原文只读 · ${contentLineCount(rawValue)} 行 · ${snapshot.sizeBytes ?? 0} B`)); + } + return e("div", { className: "file-workbench-raw" }, + e("textarea", { className: "file-workbench-raw-editor", value: rawValue, readOnly: !editable, spellCheck: false, "aria-label": `${file.label} 原文模式编辑`, onChange: (event: { target: { value: string } }) => setRawDraft((current) => ({ ...current, [file.key]: event.target.value })) }), + e("div", { className: "console-row-actions file-workbench-actions" }, + e("button", { type: "button", className: "icon-command", disabled: !editable || rawValue === currentContent, onClick: () => onPreview("source", rawValue, currentContent) }, "预览改动"), + e("button", { type: "button", className: "primary-command", disabled: !editable || !canWriteFile || !preview || preview.mode !== "source", onClick: onWrite }, "提交写入") + ), + preview && preview.mode === "source" ? diffPreview(e, preview) : null, + e("p", { className: "page-status" }, editable ? "原文模式会整文件写入声明 file key;提交前请先预览差异。" : "原文配置当前只读。") + ); +} + +function diffPreview(e: ReactLike["createElement"], preview: Exclude) { + return e("section", { className: "file-workbench-diff", "aria-label": "文件改动预览" }, + e("div", { className: "console-row" }, e("span", null, "差异预览"), e("strong", null, preview.summary)), + e("pre", { className: "runtime-task-log" }, preview.lines.map((line) => `${line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " "} ${line.text}`).join("\n")) + ); +} + +function usePluginState(react: ReactLike, initialState: T): [T, StateSetter] { + return react.useState ? react.useState(initialState) : [initialState, () => undefined]; +} + +function normalizeWorkspace(workspace?: SCUMFeatureWorkspace): NormalizedWorkspace { + const directories = workspace?.directories?.length ? workspace.directories : fallbackDirectories; + const files = workspace?.files?.length ? workspace.files : fallbackFiles; + const configFields = workspace?.configFields?.length ? workspace.configFields : configurationCatalog; + return { defaultDirectoryKey: workspace?.defaultDirectoryKey && directories.some((directory) => directory.key === workspace.defaultDirectoryKey) ? workspace.defaultDirectoryKey : directories[0]?.key ?? "scum-config", directories, files, configFields }; +} + +function initialFileKey(workspace: NormalizedWorkspace, directoryKey: string): string { + return workspace.files.find((file) => file.directoryKey === directoryKey && file.kind === "config")?.key ?? workspace.files.find((file) => file.directoryKey === directoryKey)?.key ?? workspace.files[0]?.key ?? ""; +} + +function directoryRelativePath(key: string): string | undefined { return directoryRelativePaths[key]; } + +function controlLabel(field: SCUMConfigField): string { + const range = field.minimum !== undefined || field.maximum !== undefined ? `范围 ${field.minimum ?? "不限"}-${field.maximum ?? "不限"}` : ""; + const label = field.control === "boolean" ? "是/否选择" : field.control === "number" || field.control === "port" ? "滑动输入" : "文本填空"; + return [label, range].filter(Boolean).join(" · "); +} + +function normalizeBoolean(value: string): boolean { + return /^(true|1|yes|on)$/i.test(String(value).trim()); +} + +function parseIniAssignments(content: string): Record { + const values: Record = {}; + for (const line of content.split("\n")) { + const parsed = parseIniAssignment(line); + if (parsed) values[parsed.key] = parsed.value; + } + return values; +} + +function unknownIniAssignments(content: string, fields: readonly SCUMConfigField[]): Array<{ key: string; value: string; index: number }> { + const known = new Set(fields.map((field) => field.configKey)); + const unknown: Array<{ key: string; value: string; index: number }> = []; + content.split("\n").forEach((line, index) => { + const parsed = parseIniAssignment(line); + if (parsed && !known.has(parsed.key)) unknown.push({ ...parsed, index }); + }); + return unknown; +} + +function parseIniAssignment(line: string): { key: string; value: string } | null { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";") || trimmed.startsWith("[")) return null; + const separator = line.indexOf("="); + if (separator < 1) return null; + return { key: line.slice(0, separator).trim(), value: line.slice(separator + 1).trim() }; +} + +function composeIniContent(content: string, fields: readonly SCUMConfigField[], draft: Record): string { + const byField = new Map(fields.map((field) => [field.key, field])); + const changes = new Map(); + for (const [fieldKey, value] of Object.entries(draft)) { + const field = byField.get(fieldKey); + if (field) changes.set(field.configKey, value); + } + if (changes.size === 0) return content; + const applied = new Set(); + const lines = content.split("\n").map((line) => { + const parsed = parseIniAssignment(line); + if (!parsed || !changes.has(parsed.key)) return line; + applied.add(parsed.key); + return `${line.slice(0, line.indexOf("=") + 1)}${changes.get(parsed.key) ?? ""}`; + }); + for (const [key, value] of changes) { + if (!applied.has(key)) lines.push(`${key}=${value}`); + } + return content.endsWith("\n") ? lines.join("\n") : lines.join("\n").replace(/\n$/, ""); +} + +function decodeRawContent(content: string, encoding: RawEncoding): string { + if (encoding === "utf-8") return content; + const bytes = Uint8Array.from(Array.from(content), (char) => char.charCodeAt(0) & 0xff); + if (typeof TextDecoder !== "undefined") return new TextDecoder("utf-16le").decode(bytes); + let decoded = ""; + for (let index = 0; index < bytes.length; index += 2) decoded += String.fromCharCode(bytes[index] | ((bytes[index + 1] ?? 0) << 8)); + return decoded; +} + +function buildSimpleDiff(current: string, proposed: string): { summary: string; lines: readonly DiffLine[] } { + const currentLines = current.split("\n"); + const proposedLines = proposed.split("\n"); + const max = Math.max(currentLines.length, proposedLines.length); + const lines: DiffLine[] = []; + let added = 0; + let removed = 0; + for (let index = 0; index < max; index += 1) { + const before = currentLines[index]; + const after = proposedLines[index]; + if (before === after) { + if (before !== undefined) lines.push({ kind: "same", text: before }); + continue; + } + if (before !== undefined) { removed += 1; lines.push({ kind: "removed", text: before }); } + if (after !== undefined) { added += 1; lines.push({ kind: "added", text: after }); } + } + return { summary: `+${added} / -${removed} 行变更`, lines }; +} + +function contentLineCount(content: string): number { + return content ? content.split("\n").length : 0; +} diff --git a/plugins/examples/scum-server-plugin/features/schemas.ts b/plugins/examples/scum-server-plugin/features/schemas.ts index 5b5814f..0ed2614 100644 --- a/plugins/examples/scum-server-plugin/features/schemas.ts +++ b/plugins/examples/scum-server-plugin/features/schemas.ts @@ -3,11 +3,11 @@ import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMSta // These are safe fallback allowlists. A Companion schema probe may narrow them // per server, but a game version never enables or disables a feature. export const configurationCatalog: readonly SCUMConfigField[] = [ - { key: "server-name", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" }, - { key: "game-port", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7779", restartImpact: "restart-required" }, - { key: "query-port", configKey: "QueryPort", label: "查询端口", description: "服务器查询和状态发现所使用的端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "27015", restartImpact: "restart-required" }, - { key: "max-players", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "128", restartImpact: "restart-required" }, - { key: "welcome-message", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" } + { key: "server-name", fileKey: "scum-server-settings", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" }, + { key: "game-port", fileKey: "scum-server-settings", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7779", restartImpact: "restart-required" }, + { key: "query-port", fileKey: "scum-server-settings", configKey: "QueryPort", label: "查询端口", description: "服务器查询和状态发现所使用的端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "27015", restartImpact: "restart-required" }, + { key: "max-players", fileKey: "scum-server-settings", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "128", restartImpact: "restart-required" }, + { key: "welcome-message", fileKey: "scum-server-settings", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" } ]; export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }]; export const stateFieldCatalog: readonly Omit[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }]; diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index ee80efc..27c112a 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -373,11 +373,11 @@ "dependencyPolicy": "required", "approvalRequired": ["disable", "rollback", "retire"] }, - "pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.2", "bundleIntegritySha256": "sha256:3b39507d1471f8d62d25001a11b43c664dbb5a5bef91ed6944b512e6e60099a7", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "ai.invoke"], "featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "vehicle.spawn", "trajectory.collect"] }], + "pages": [{ "key": "files-config", "title": "文件管理", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read"], "bridgeActions": ["server.instances.read", "files.request", "logs.query"], "featureKeys": ["config.manage"] }], "fileWorkspace": { "defaultDirectoryKey": "scum-config", "directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }], - "files": [{ "key": "scum-server-settings", "directoryKey": "scum-config", "label": "ServerSettings.ini", "kind": "config", "editable": true }, { "key": "scum-server-log", "directoryKey": "scum-logs", "label": "SCUM Server.log", "kind": "log", "streamKey": "scum.server" }, { "key": "scum-chat-log", "directoryKey": "scum-logs", "label": "SCUM Chat.log", "kind": "log", "streamKey": "scum.chat" }], + "files": [{ "key": "scum-server-settings", "directoryKey": "scum-config", "label": "ServerSettings.ini", "kind": "config", "editable": true }, { "key": "scum-game-config", "directoryKey": "scum-config", "label": "Game.ini", "kind": "config" }, { "key": "scum-engine-config", "directoryKey": "scum-config", "label": "Engine.ini", "kind": "config" }, { "key": "scum-game-user-settings", "directoryKey": "scum-config", "label": "GameUserSettings.ini", "kind": "config" }, { "key": "scum-admin-log", "directoryKey": "scum-logs", "label": "Admin.log", "kind": "log", "streamKey": "scum.admin" }, { "key": "scum-chat-log", "directoryKey": "scum-logs", "label": "Chat.log", "kind": "log", "streamKey": "scum.chat" }, { "key": "scum-kill-log", "directoryKey": "scum-logs", "label": "Kill.log", "kind": "log", "streamKey": "scum.kill" }, { "key": "scum-login-log", "directoryKey": "scum-logs", "label": "Login.log", "kind": "log", "streamKey": "scum.login" }, { "key": "scum-server-log", "directoryKey": "scum-logs", "label": "Server.log", "kind": "log", "streamKey": "scum.server" }], "configFields": [{ "key": "server-name", "fileKey": "scum-server-settings", "configKey": "ServerName", "label": "服务器名称", "description": "显示在服务器浏览器与玩家连接界面。", "control": "text", "defaultValue": "SCUM Server", "restartImpact": "restart-required" }, { "key": "game-port", "fileKey": "scum-server-settings", "configKey": "GamePort", "label": "游戏端口", "description": "玩家连接所使用的游戏端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "7779", "restartImpact": "restart-required" }, { "key": "query-port", "fileKey": "scum-server-settings", "configKey": "QueryPort", "label": "查询端口", "description": "服务器查询和状态发现所使用的端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "27015", "restartImpact": "restart-required" }, { "key": "max-players", "fileKey": "scum-server-settings", "configKey": "MaxPlayers", "label": "最大玩家数", "description": "允许同时进入服务器的玩家上限。", "control": "number", "minimum": 1, "maximum": 128, "defaultValue": "128", "restartImpact": "restart-required" }, { "key": "welcome-message", "fileKey": "scum-server-settings", "configKey": "WelcomeMessage", "label": "欢迎消息", "description": "登录成功后由已声明的服务器扩展显示给玩家。", "control": "text", "defaultValue": "", "restartImpact": "none" }] }, "ai": { diff --git a/plugins/examples/scum-server-plugin/page-bundle/index.ts b/plugins/examples/scum-server-plugin/page-bundle/index.ts index 7dfcb5b..9cb6788 100644 --- a/plugins/examples/scum-server-plugin/page-bundle/index.ts +++ b/plugins/examples/scum-server-plugin/page-bundle/index.ts @@ -1,6 +1,6 @@ import { renderSCUMFeaturePage } from "../features/page.js"; import type { SCUMFeatureWorkspace } from "../features/contracts.js"; -export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.2", integritySha256: "sha256:3b39507d1471f8d62d25001a11b43c664dbb5a5bef91ed6944b512e6e60099a7" }; +export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.3", integritySha256: "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2" }; -export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspace: input.workspace as SCUMFeatureWorkspace | undefined }); } +export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspace: input.workspace as SCUMFeatureWorkspace | undefined, workspaceActions: input.workspaceActions }); } diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index b41e4b5..7687fe6 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -217,6 +217,10 @@ describe("plugin manifest validation", () => { expect(installScript).toContain("steamcmd\\steamapps\\common\\SCUM Server"); expect(installScript).toContain(".scum-exe-path"); expect(startScript).toContain(".scum-exe-path"); + expect(startScript).toContain("call :install_or_update"); + expect(startScript).toContain("scum-install-update.cmd"); + expect(startScript).toContain("SCUM executable was not found. Running plugin install/update script."); + expect(startScript).not.toContain("SCUM.log"); expect(startScript).toContain("%SERVER_INSTALL_DIR_WINDOWS%\\%SERVER_EXECUTABLE_REF:/=\\%"); expect(startScript).toContain("steamcmd\\steamapps\\common\\SCUM Server"); expect(startScript).toContain("SCUM_WORKDIR"); @@ -438,6 +442,12 @@ describe("plugin manifest validation", () => { pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[] }>; }; pages: Array<{ key: string }>; + fileWorkspace?: { + defaultDirectoryKey: string; + directories: Array<{ key: string; label: string; scope: string }>; + files: Array<{ key: string; directoryKey: string; label: string; kind: string; streamKey?: string; editable?: boolean }>; + configFields: Array<{ key: string; fileKey: string; configKey: string; label: string }>; + }; runtimeProfiles?: { lifecycleProfiles?: Array<{ key: string; capabilities?: string[] }>; logSources?: Array<{ key: string }>; @@ -477,6 +487,10 @@ describe("plugin manifest validation", () => { "maintenance.prepare" ])); expect(manifest.pages.map((page) => page.key)).toContain("files-config"); + expect(manifest.fileWorkspace?.defaultDirectoryKey).toBe("scum-config"); + expect(manifest.fileWorkspace?.directories.map((directory) => directory.key)).toEqual(["scum-config", "scum-logs"]); + expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-game-config", "scum-engine-config", "scum-game-user-settings", "scum-admin-log", "scum-chat-log", "scum-kill-log", "scum-login-log", "scum-server-log"])); + expect(manifest.fileWorkspace?.configFields.every((field) => field.fileKey === "scum-server-settings")).toBe(true); expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command"); expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-client-events"])); }); diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 5197f28..8f4ffa5 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -1,10 +1,15 @@ import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js"; import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js"; import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js"; +const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8"); + describe("SCUM plugin feature module", () => { it("owns runtime allowlists without a version gate", () => { expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message"); @@ -46,35 +51,84 @@ describe("SCUM plugin feature module", () => { expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false }); }); - it("renders plugin-owned configuration, player, reward, state, and trajectory panels with scoped permissions", () => { - const nodes: string[] = []; const buttons = new Map(); - const react = { createElement: (type: unknown, props: Record | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } }; - renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true, features: [{ key: "config.manage", available: true }, { key: "player.intelligence", available: false, reason: "no event producer" }, { key: "reward.delivery", available: false, reason: "no delivery handler" }, { key: "state.patch", available: false, reason: "no state handler" }, { key: "vehicle.spawn", available: false, reason: "no vehicle handler" }, { key: "trajectory.collect", available: false, reason: "no position producer" }] }, workspace: {} }); - expect(nodes).toContain("section:SCUM 配置工作台"); - expect(nodes).toContain("section:SCUM 玩家档案"); - expect(nodes).toContain("section:SCUM 礼物与通知"); - expect(nodes).toContain("section:SCUM 受控状态修改"); - expect(nodes).toContain("section:SCUM 受限载具生成"); - expect(nodes).toContain("section:SCUM 地图轨迹"); - expect(buttons.get("读取配置")).toBe(false); - expect(buttons.get("查询玩家")).toBe(true); - expect(buttons.get("申请投递")).toBe(true); - expect(buttons.get("创建修改申请")).toBe(true); - expect(buttons.get("生成载具")).toBe(true); - expect(buttons.get("读取轨迹")).toBe(true); + it("renders the compact two-level file management workbench without legacy stacked panels", () => { + const view = renderAndCollect(); + expect(view.nodes).toContain("section:SCUM 文件管理"); + expect(view.nodes).toContain("aside:SCUM 文件两级菜单"); + expect(view.nodes).toContain("article:文件 ServerSettings.ini"); + expect(view.texts.join("\n")).toContain("/SCUM/Saved/Config/WindowsServer"); + expect(view.texts.join("\n")).toContain("/SCUM/Saved/SaveFiles/Logs"); + expect(view.texts).toContain("ServerSettings.ini"); + expect(view.texts).toContain("Game.ini"); + expect(view.texts).toContain("配置表单"); + expect(view.texts).toContain("原文模式"); + expect(view.buttons.find((button) => button.label === "读取文件")?.disabled).toBe(false); + expect(view.buttons.find((button) => button.label === "刷新结果")?.disabled).toBe(false); + for (const legacyText of ["玩家档案", "礼物", "受控状态", "载具", "地图轨迹", "查询玩家"]) expect(view.texts.join("\n")).not.toContain(legacyText); }); - it("fails closed when a generally online Companion omits feature availability", () => { - const buttons = new Map(); - const react = { createElement: (type: unknown, props: Record | null, ...children: unknown[]) => { if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } }; - renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true }, workspace: {} }); - for (const label of ["读取配置", "查询玩家", "申请投递", "创建修改申请", "生成载具", "读取轨迹"]) expect(buttons.get(label)).toBe(true); + it("keeps declared log files in read-only raw view with encoding controls", () => { + const view = renderAndCollect({ directoryKey: "scum-logs", fileKey: "scum-admin-log" }); + expect(view.nodes).toContain("article:文件 Admin.log"); + expect(view.texts).toContain("UTF-8"); + expect(view.texts).toContain("UTF-16 LE"); + expect(view.texts.join("\n")).toContain("尚未读取此日志文件的受控内容。"); + expect(view.nodes.some((node) => node.startsWith("textarea:"))).toBe(false); + expect(view.texts).not.toContain("配置表单"); + expect(view.texts).not.toContain("提交写入"); }); - it("opens vehicle spawning only for the declared Companion handler", () => { - const buttons = new Map(); - const react = { createElement: (type: unknown, props: Record | null, ...children: unknown[]) => { if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } }; - renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.command"] }, availability: { available: true, features: [{ key: "vehicle.spawn", available: true }] }, workspace: {} }); - expect(buttons.get("生成载具")).toBe(false); + it("renders current config values, unknown fields, encoding switch, and guarded write actions after a read", () => { + const view = renderAndCollect({ snapshot: { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "ready", content: "ServerName=Qinghuo\nMaxPlayers=96\nCustomKey=keep\n", version: 3, checksum: "sha256:cfg", sizeBytes: 48 } }); + expect(view.texts).toContain("UTF-8"); + expect(view.texts).toContain("UTF-16 LE"); + expect(view.texts).toContain("未建模配置项"); + expect(view.texts).toContain("CustomKey"); + expect(pageSource).toContain('e("option", { value: "true" }, "是")'); + expect(pageSource).toContain('type: "range"'); + expect(view.buttons.find((button) => button.label === "预览改动")?.disabled).toBe(true); + expect(view.buttons.find((button) => button.label === "提交写入")?.disabled).toBe(true); + }); + + it("loads snapshots on selection or manual refresh without interval polling", () => { + expect(pageSource).toContain("getFileSnapshot(selectedFile.key)"); + expect(pageSource).toContain("刷新结果"); + expect(pageSource).not.toContain("setInterval"); + expect(pageSource).not.toContain("setTimeout"); }); }); + +function renderAndCollect(options: { snapshot?: Record; permissions?: string[]; directoryKey?: string; fileKey?: string } = {}) { + const nodes: string[] = []; + const texts: string[] = []; + const buttons: Array<{ label: string; disabled: boolean }> = []; + const collectText = (value: unknown): void => { if (typeof value === "string") texts.push(value); else if (Array.isArray(value)) value.forEach(collectText); else if (value && typeof value === "object" && "children" in value) collectText((value as { children?: unknown }).children); }; + let stateCall = 0; + const react = { + createElement: (type: unknown, props: Record | null, ...children: unknown[]) => { + if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); + children.forEach(collectText); + if (type === "button") buttons.push({ label: String(children[0]), disabled: Boolean(props?.disabled) }); + return { type, props, children }; + }, + useEffect: () => undefined, + useState: (initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => { + stateCall += 1; + if (stateCall === 1 && options.directoryKey) return [options.directoryKey as T, () => undefined]; + if (stateCall === 2 && options.fileKey) return [options.fileKey as T, () => undefined]; + if (stateCall === 7 && options.snapshot) return [options.snapshot as T, () => undefined]; + return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined]; + } + }; + renderPluginPage(react, { + context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.files.read", "server.files.write", "server.logs.read"] }, + availability: { available: true, features: [{ key: "config.manage", available: true }] }, + workspace: {}, + workspaceActions: { + requestFile: async (fileKey: string) => ({ status: "queued", message: fileKey }), + getFileSnapshot: async (fileKey: string) => ({ serverInstanceId: "server-1", pluginId: "game.scum", key: fileKey, state: "not-read" }), + writeFile: async (fileKey: string) => ({ status: "queued", message: fileKey }) + } + }); + return { nodes, texts, buttons }; +}