feat(host): user-defined custom display presets
Save named bundles of the display-management policy (the six behavior axes
plus the game-session axis) as custom presets, alongside the built-ins. A
custom preset is data — stored in <config>/display-presets.json — not a Preset
enum variant, so DisplayPolicy::effective() stays pure and the built-in set is
untouched; applying one writes a Custom policy via the existing PUT
/display/settings.
- policy.rs: CustomPreset/CustomPresetInput + load/add/update/delete store
- mgmt.rs: GET/POST /display/presets + PUT/DELETE /display/presets/{id},
surfaced on GET /display/settings
- web console: custom-preset cards with save-as / edit / delete + i18n
- regenerated api/openapi.json; docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -190,6 +190,237 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/display/presets": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "List the saved custom presets",
|
||||||
|
"description": "The operator's named field-bundles (`display-presets.json`). These also ride the\n`GET /display/settings` response (`custom_presets`), so the console rarely needs this directly.",
|
||||||
|
"operationId": "listCustomPresets",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The saved custom presets",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/CustomPreset"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "Save a custom preset",
|
||||||
|
"description": "Stores a named bundle of the display-behavior axes (+ the game-session axis) the operator can\napply later. The host assigns a stable id, returned in the body. Applying a preset is a\n`PUT /display/settings` with a `Custom` policy carrying its `fields` — no separate apply route.",
|
||||||
|
"operationId": "createCustomPreset",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CustomPresetInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Preset created",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CustomPreset"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Empty name",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not persist the catalog",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/display/presets/{id}": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "Update a custom preset",
|
||||||
|
"operationId": "updateCustomPreset",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The custom preset id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CustomPresetInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Preset updated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CustomPreset"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Empty name",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No custom preset with that id",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not persist the catalog",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "Delete a custom preset",
|
||||||
|
"description": "Removes it from the catalog. The active policy is untouched — if this preset was the one applied,\nthe running behavior stays exactly as it was (the catalog and `display-settings.json` are decoupled).",
|
||||||
|
"operationId": "deleteCustomPreset",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The custom preset id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Preset deleted"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No custom preset with that id",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not persist the catalog",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/display/release": {
|
"/api/v1/display/release": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2220,6 +2451,52 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CustomPreset": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A user-defined named preset: a saved bundle of the six display-behavior axes (exactly what a\nbuilt-in [`Preset`] expands to) plus the orthogonal game-session axis, that the operator names\nand applies from the console.\n\nUnlike the built-in [`Preset`]s (a closed enum), custom presets are **data** — a catalog stored in\n`<config>/display-presets.json`. Applying one writes a `Custom` [`DisplayPolicy`] carrying these\nfields (the console reuses `PUT /display/settings`), so [`DisplayPolicy::effective`] stays pure and\nthe built-in set is never touched. The catalog is decoupled from the active `display-settings.json`:\nediting or deleting a preset never mutates the running policy (re-apply to adopt a change).",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"fields"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"fields": {
|
||||||
|
"$ref": "#/components/schemas/EffectivePolicy",
|
||||||
|
"description": "The six display-behavior axes this preset applies (the same shape a built-in preset expands to)."
|
||||||
|
},
|
||||||
|
"game_session": {
|
||||||
|
"$ref": "#/components/schemas/GameSession",
|
||||||
|
"description": "The game-session routing this preset applies (orthogonal to the six axes; see [`GameSession`]).\nA custom preset captures the operator's *full* setup, so — unlike a built-in preset — applying\none does set this axis."
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "User-facing name shown on the preset card; editable."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CustomPresetInput": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Request body to create or replace a custom preset (no `id` — the host owns it).",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"fields"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"fields": {
|
||||||
|
"$ref": "#/components/schemas/EffectivePolicy"
|
||||||
|
},
|
||||||
|
"game_session": {
|
||||||
|
"$ref": "#/components/schemas/GameSession"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"DisplayLayoutRequest": {
|
"DisplayLayoutRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`).",
|
"description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`).",
|
||||||
@@ -2284,6 +2561,7 @@
|
|||||||
"configured",
|
"configured",
|
||||||
"effective",
|
"effective",
|
||||||
"presets",
|
"presets",
|
||||||
|
"custom_presets",
|
||||||
"enforced"
|
"enforced"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2291,6 +2569,13 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "True once a `display-settings.json` exists (the console has configured this host)."
|
"description": "True once a `display-settings.json` exists (the console has configured this host)."
|
||||||
},
|
},
|
||||||
|
"custom_presets": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/CustomPreset"
|
||||||
|
},
|
||||||
|
"description": "The operator's saved custom presets (`display-presets.json`) — named field-bundles rendered\nalongside the built-ins. Managed via `POST/PUT/DELETE /display/presets`; applied by writing a\n`Custom` policy carrying the preset's fields."
|
||||||
|
},
|
||||||
"effective": {
|
"effective": {
|
||||||
"$ref": "#/components/schemas/EffectivePolicy",
|
"$ref": "#/components/schemas/EffectivePolicy",
|
||||||
"description": "The effective (preset-expanded) policy currently in force."
|
"description": "The effective (preset-expanded) policy currently in force."
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(get_display_state))
|
.routes(routes!(get_display_state))
|
||||||
.routes(routes!(release_display))
|
.routes(routes!(release_display))
|
||||||
.routes(routes!(set_display_layout))
|
.routes(routes!(set_display_layout))
|
||||||
|
.routes(routes!(list_custom_presets, create_custom_preset))
|
||||||
|
.routes(routes!(update_custom_preset, delete_custom_preset))
|
||||||
.routes(routes!(get_status))
|
.routes(routes!(get_status))
|
||||||
.routes(routes!(get_local_summary))
|
.routes(routes!(get_local_summary))
|
||||||
.routes(routes!(list_paired_clients))
|
.routes(routes!(list_paired_clients))
|
||||||
@@ -993,6 +995,10 @@ struct DisplaySettingsState {
|
|||||||
effective: crate::vdisplay::policy::EffectivePolicy,
|
effective: crate::vdisplay::policy::EffectivePolicy,
|
||||||
/// Every named preset and what it expands to (for the picker's preview).
|
/// Every named preset and what it expands to (for the picker's preview).
|
||||||
presets: Vec<PresetInfo>,
|
presets: Vec<PresetInfo>,
|
||||||
|
/// The operator's saved custom presets (`display-presets.json`) — named field-bundles rendered
|
||||||
|
/// alongside the built-ins. Managed via `POST/PUT/DELETE /display/presets`; applied by writing a
|
||||||
|
/// `Custom` policy carrying the preset's fields.
|
||||||
|
custom_presets: Vec<crate::vdisplay::policy::CustomPreset>,
|
||||||
/// Option names this build enforces right now. All five axes are now acted on (keep_alive +
|
/// Option names this build enforces right now. All five axes are now acted on (keep_alive +
|
||||||
/// topology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console
|
/// topology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console
|
||||||
/// reads this to know which controls are live vs. "coming soon" (per-backend nuance, e.g. layout
|
/// reads this to know which controls are live vs. "coming soon" (per-backend nuance, e.g. layout
|
||||||
@@ -1037,6 +1043,7 @@ fn display_settings_state() -> DisplaySettingsState {
|
|||||||
settings,
|
settings,
|
||||||
configured,
|
configured,
|
||||||
presets,
|
presets,
|
||||||
|
custom_presets: policy::load_custom_presets(),
|
||||||
enforced: vec![
|
enforced: vec![
|
||||||
"keep_alive".into(),
|
"keep_alive".into(),
|
||||||
"topology".into(),
|
"topology".into(),
|
||||||
@@ -1266,6 +1273,109 @@ async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Resp
|
|||||||
Json(display_settings_state()).into_response()
|
Json(display_settings_state()).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List the saved custom presets
|
||||||
|
///
|
||||||
|
/// The operator's named field-bundles (`display-presets.json`). These also ride the
|
||||||
|
/// `GET /display/settings` response (`custom_presets`), so the console rarely needs this directly.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/display/presets",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "listCustomPresets",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "The saved custom presets", body = Vec<crate::vdisplay::policy::CustomPreset>),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn list_custom_presets() -> Json<Vec<crate::vdisplay::policy::CustomPreset>> {
|
||||||
|
Json(crate::vdisplay::policy::load_custom_presets())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a custom preset
|
||||||
|
///
|
||||||
|
/// Stores a named bundle of the display-behavior axes (+ the game-session axis) the operator can
|
||||||
|
/// apply later. The host assigns a stable id, returned in the body. Applying a preset is a
|
||||||
|
/// `PUT /display/settings` with a `Custom` policy carrying its `fields` — no separate apply route.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/display/presets",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "createCustomPreset",
|
||||||
|
request_body = crate::vdisplay::policy::CustomPresetInput,
|
||||||
|
responses(
|
||||||
|
(status = CREATED, description = "Preset created", body = crate::vdisplay::policy::CustomPreset),
|
||||||
|
(status = BAD_REQUEST, description = "Empty name", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn create_custom_preset(
|
||||||
|
ApiJson(input): ApiJson<crate::vdisplay::policy::CustomPresetInput>,
|
||||||
|
) -> Response {
|
||||||
|
if input.name.trim().is_empty() {
|
||||||
|
return api_error(StatusCode::BAD_REQUEST, "preset name must not be empty");
|
||||||
|
}
|
||||||
|
match crate::vdisplay::policy::add_custom_preset(input) {
|
||||||
|
Ok(preset) => (StatusCode::CREATED, Json(preset)).into_response(),
|
||||||
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a custom preset
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/display/presets/{id}",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "updateCustomPreset",
|
||||||
|
params(("id" = String, Path, description = "The custom preset id")),
|
||||||
|
request_body = crate::vdisplay::policy::CustomPresetInput,
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Preset updated", body = crate::vdisplay::policy::CustomPreset),
|
||||||
|
(status = BAD_REQUEST, description = "Empty name", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = NOT_FOUND, description = "No custom preset with that id", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn update_custom_preset(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
ApiJson(input): ApiJson<crate::vdisplay::policy::CustomPresetInput>,
|
||||||
|
) -> Response {
|
||||||
|
if input.name.trim().is_empty() {
|
||||||
|
return api_error(StatusCode::BAD_REQUEST, "preset name must not be empty");
|
||||||
|
}
|
||||||
|
match crate::vdisplay::policy::update_custom_preset(&id, input) {
|
||||||
|
Ok(Some(preset)) => Json(preset).into_response(),
|
||||||
|
Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom preset with that id"),
|
||||||
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a custom preset
|
||||||
|
///
|
||||||
|
/// Removes it from the catalog. The active policy is untouched — if this preset was the one applied,
|
||||||
|
/// the running behavior stays exactly as it was (the catalog and `display-settings.json` are decoupled).
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/display/presets/{id}",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "deleteCustomPreset",
|
||||||
|
params(("id" = String, Path, description = "The custom preset id")),
|
||||||
|
responses(
|
||||||
|
(status = NO_CONTENT, description = "Preset deleted"),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = NOT_FOUND, description = "No custom preset with that id", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn delete_custom_preset(Path(id): Path<String>) -> Response {
|
||||||
|
match crate::vdisplay::policy::delete_custom_preset(&id) {
|
||||||
|
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom preset with that id"),
|
||||||
|
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Live host status
|
/// Live host status
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
|
|||||||
@@ -23,10 +23,11 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
/// How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)
|
/// How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)
|
||||||
@@ -458,10 +459,163 @@ pub fn prefs() -> &'static DisplayPolicyStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// User-defined custom presets (`<config>/display-presets.json`)
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A user-defined named preset: a saved bundle of the six display-behavior axes (exactly what a
|
||||||
|
/// built-in [`Preset`] expands to) plus the orthogonal game-session axis, that the operator names
|
||||||
|
/// and applies from the console.
|
||||||
|
///
|
||||||
|
/// Unlike the built-in [`Preset`]s (a closed enum), custom presets are **data** — a catalog stored in
|
||||||
|
/// `<config>/display-presets.json`. Applying one writes a `Custom` [`DisplayPolicy`] carrying these
|
||||||
|
/// fields (the console reuses `PUT /display/settings`), so [`DisplayPolicy::effective`] stays pure and
|
||||||
|
/// the built-in set is never touched. The catalog is decoupled from the active `display-settings.json`:
|
||||||
|
/// editing or deleting a preset never mutates the running policy (re-apply to adopt a change).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct CustomPreset {
|
||||||
|
/// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).
|
||||||
|
pub id: String,
|
||||||
|
/// User-facing name shown on the preset card; editable.
|
||||||
|
pub name: String,
|
||||||
|
/// The six display-behavior axes this preset applies (the same shape a built-in preset expands to).
|
||||||
|
pub fields: EffectivePolicy,
|
||||||
|
/// The game-session routing this preset applies (orthogonal to the six axes; see [`GameSession`]).
|
||||||
|
/// A custom preset captures the operator's *full* setup, so — unlike a built-in preset — applying
|
||||||
|
/// one does set this axis.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_session: GameSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body to create or replace a custom preset (no `id` — the host owns it).
|
||||||
|
#[derive(Clone, Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct CustomPresetInput {
|
||||||
|
pub name: String,
|
||||||
|
pub fields: EffectivePolicy,
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_session: GameSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn custom_presets_path() -> PathBuf {
|
||||||
|
crate::gamestream::config_dir().join("display-presets.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp a saved preset's fields to their valid ranges — the same bounds [`DisplayPolicy::sanitized`]
|
||||||
|
/// enforces, so a preset can never carry an out-of-range `max_displays` that a later apply would reject.
|
||||||
|
fn sanitize_preset_fields(mut fields: EffectivePolicy) -> EffectivePolicy {
|
||||||
|
fields.max_displays = fields.max_displays.clamp(1, 16);
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the saved custom presets (empty + non-fatal if the file is absent or malformed — a bad
|
||||||
|
/// catalog never breaks the console's settings GET).
|
||||||
|
pub fn load_custom_presets() -> Vec<CustomPreset> {
|
||||||
|
match std::fs::read(custom_presets_path()) {
|
||||||
|
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||||
|
tracing::warn!(error = %e, "display-presets.json malformed — ignoring custom presets");
|
||||||
|
Vec::new()
|
||||||
|
}),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the catalog (private dir, temp-write + atomic rename — the [`DisplayPolicyStore::set`]
|
||||||
|
/// discipline, so a crash mid-write never truncates it).
|
||||||
|
fn save_custom_presets(presets: &[CustomPreset]) -> Result<()> {
|
||||||
|
let path = custom_presets_path();
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
crate::gamestream::create_private_dir(dir)?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(presets)?)?;
|
||||||
|
std::fs::rename(&tmp, &path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 12 hex chars from the name + wall-clock nanos — collision-free in practice, no uuid dep (the
|
||||||
|
/// [`crate::library`] custom-entry id scheme).
|
||||||
|
fn new_preset_id(name: &str) -> String {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
hex::encode(&Sha256::digest(format!("{name}:{nanos}").as_bytes())[..6])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a custom preset, returning it with its assigned id.
|
||||||
|
pub fn add_custom_preset(input: CustomPresetInput) -> Result<CustomPreset> {
|
||||||
|
let mut presets = load_custom_presets();
|
||||||
|
let preset = CustomPreset {
|
||||||
|
id: new_preset_id(&input.name),
|
||||||
|
name: input.name,
|
||||||
|
fields: sanitize_preset_fields(input.fields),
|
||||||
|
game_session: input.game_session,
|
||||||
|
};
|
||||||
|
presets.push(preset.clone());
|
||||||
|
save_custom_presets(&presets)?;
|
||||||
|
Ok(preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace a custom preset's fields (id preserved). `None` ⇒ no preset with that id.
|
||||||
|
pub fn update_custom_preset(id: &str, input: CustomPresetInput) -> Result<Option<CustomPreset>> {
|
||||||
|
let mut presets = load_custom_presets();
|
||||||
|
let Some(slot) = presets.iter_mut().find(|p| p.id == id) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
slot.name = input.name;
|
||||||
|
slot.fields = sanitize_preset_fields(input.fields);
|
||||||
|
slot.game_session = input.game_session;
|
||||||
|
let updated = slot.clone();
|
||||||
|
save_custom_presets(&presets)?;
|
||||||
|
Ok(Some(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a custom preset. `false` ⇒ no preset with that id.
|
||||||
|
pub fn delete_custom_preset(id: &str) -> Result<bool> {
|
||||||
|
let mut presets = load_custom_presets();
|
||||||
|
let before = presets.len();
|
||||||
|
presets.retain(|p| p.id != id);
|
||||||
|
if presets.len() == before {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
save_custom_presets(&presets)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_preset_serde_roundtrips_and_defaults_game_session() {
|
||||||
|
let preset = CustomPreset {
|
||||||
|
id: "abc123".into(),
|
||||||
|
name: "My Rig".into(),
|
||||||
|
fields: preset_fields(Preset::GamingRig).unwrap(),
|
||||||
|
game_session: GameSession::Dedicated,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&preset).unwrap();
|
||||||
|
assert_eq!(serde_json::from_str::<CustomPreset>(&json).unwrap(), preset);
|
||||||
|
|
||||||
|
// A catalog written before `game_session` existed still loads (defaults to `Auto`).
|
||||||
|
let legacy: CustomPreset = serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "x",
|
||||||
|
"name": "Legacy",
|
||||||
|
"fields": serde_json::to_value(preset_fields(Preset::Default).unwrap()).unwrap(),
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(legacy.game_session, GameSession::Auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_preset_fields_clamps_max_displays() {
|
||||||
|
let mut f = preset_fields(Preset::Default).unwrap();
|
||||||
|
f.max_displays = 999;
|
||||||
|
assert_eq!(sanitize_preset_fields(f.clone()).max_displays, 16);
|
||||||
|
f.max_displays = 0;
|
||||||
|
assert_eq!(sanitize_preset_fields(f).max_displays, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keep_alive_serializes_tagged_on_mode() {
|
fn keep_alive_serializes_tagged_on_mode() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -38,6 +38,24 @@ the individual options documented further down.
|
|||||||
| **Hot-desk** | One user at a time with fast reattach — roaming between your own devices. A second user is told the box is busy, and each device+resolution keeps its own scaling. |
|
| **Hot-desk** | One user at a time with fast reattach — roaming between your own devices. A second user is told the box is busy, and each device+resolution keeps its own scaling. |
|
||||||
| **Workstation** | The multi-monitor daily driver. Your displays come back exactly where you arranged them, with per-client identity and an exclusive desktop. |
|
| **Workstation** | The multi-monitor daily driver. Your displays come back exactly where you arranged them, with per-client identity and an exclusive desktop. |
|
||||||
|
|
||||||
|
## Save your own preset
|
||||||
|
|
||||||
|
The five above are curated starting points. When you've dialed in a setup you like — whether by
|
||||||
|
picking a preset and tweaking it or by setting every option under **Custom** — you can **save it as
|
||||||
|
your own named preset** and switch back to it in one click later.
|
||||||
|
|
||||||
|
- **Save as preset** — names the settings currently in force (all of the options below **plus**
|
||||||
|
*Dedicated game sessions*) and adds it to the picker alongside the built-ins.
|
||||||
|
- **Apply** — selecting a saved preset writes exactly those settings, the same as picking a built-in.
|
||||||
|
- **Edit / delete** — rename a saved preset, update it to your current settings, or remove it. Deleting
|
||||||
|
a preset never changes what's running — it only takes the card out of the picker.
|
||||||
|
|
||||||
|
Unlike the built-in presets (which deliberately leave *Dedicated game sessions* alone so switching
|
||||||
|
presets never changes your game-launch routing), a **custom preset captures your full setup**,
|
||||||
|
including that axis — because it's *your* saved configuration, not a curated behavior bundle. Custom
|
||||||
|
presets live on the host in `display-presets.json` (next to `display-settings.json`); the catalog and
|
||||||
|
the active policy are independent, so editing a preset never disturbs a running session.
|
||||||
|
|
||||||
## Options reference
|
## Options reference
|
||||||
|
|
||||||
Choose **Custom** in the console to set these directly.
|
Choose **Custom** in the console to set these directly.
|
||||||
|
|||||||
@@ -108,6 +108,13 @@
|
|||||||
"display_layout_help": "Automatisch ordnet die Anzeigen nebeneinander an (links nach rechts). Manuell: Du platzierst jede selbst — ein X/Y-Editor pro Anzeige erscheint im Abschnitt „Aktive Displays“ unten, sobald zwei oder mehr streamen.",
|
"display_layout_help": "Automatisch ordnet die Anzeigen nebeneinander an (links nach rechts). Manuell: Du platzierst jede selbst — ein X/Y-Editor pro Anzeige erscheint im Abschnitt „Aktive Displays“ unten, sobald zwei oder mehr streamen.",
|
||||||
"display_layout_auto_row": "Automatisch (nebeneinander)",
|
"display_layout_auto_row": "Automatisch (nebeneinander)",
|
||||||
"display_layout_manual": "Manuell",
|
"display_layout_manual": "Manuell",
|
||||||
|
"display_preset_custom_label": "Eigene Voreinstellungen",
|
||||||
|
"display_preset_save_as": "Als Voreinstellung speichern…",
|
||||||
|
"display_preset_name": "Name der Voreinstellung",
|
||||||
|
"display_preset_edit": "Umbenennen",
|
||||||
|
"display_preset_update": "Auf aktuelle Einstellungen aktualisieren",
|
||||||
|
"display_preset_delete": "Löschen",
|
||||||
|
"display_preset_delete_confirm": "Diese eigene Voreinstellung löschen?",
|
||||||
"clients_title": "Gekoppelte Geräte",
|
"clients_title": "Gekoppelte Geräte",
|
||||||
"clients_empty": "Noch keine gekoppelten Geräte.",
|
"clients_empty": "Noch keine gekoppelten Geräte.",
|
||||||
"clients_name": "Name",
|
"clients_name": "Name",
|
||||||
|
|||||||
@@ -108,6 +108,13 @@
|
|||||||
"display_layout_help": "Auto lays displays out side by side, left to right. Manual: you position each one yourself — a per-display X/Y editor appears in the Live displays section below once two or more are streaming.",
|
"display_layout_help": "Auto lays displays out side by side, left to right. Manual: you position each one yourself — a per-display X/Y editor appears in the Live displays section below once two or more are streaming.",
|
||||||
"display_layout_auto_row": "Auto (side by side)",
|
"display_layout_auto_row": "Auto (side by side)",
|
||||||
"display_layout_manual": "Manual",
|
"display_layout_manual": "Manual",
|
||||||
|
"display_preset_custom_label": "Custom presets",
|
||||||
|
"display_preset_save_as": "Save as preset…",
|
||||||
|
"display_preset_name": "Preset name",
|
||||||
|
"display_preset_edit": "Rename",
|
||||||
|
"display_preset_update": "Update to current settings",
|
||||||
|
"display_preset_delete": "Delete",
|
||||||
|
"display_preset_delete_confirm": "Delete this custom preset?",
|
||||||
"clients_title": "Paired clients",
|
"clients_title": "Paired clients",
|
||||||
"clients_empty": "No paired clients yet.",
|
"clients_empty": "No paired clients yet.",
|
||||||
"clients_name": "Name",
|
"clients_name": "Name",
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@unom/ui/button";
|
import { Button } from "@unom/ui/button";
|
||||||
import { type FC, type ReactNode, useEffect, useState } from "react";
|
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
import { type FC, type MouseEvent, type ReactNode, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
getGetDisplayStateQueryKey,
|
getGetDisplayStateQueryKey,
|
||||||
getGetDisplaySettingsQueryKey,
|
getGetDisplaySettingsQueryKey,
|
||||||
|
useCreateCustomPreset,
|
||||||
|
useDeleteCustomPreset,
|
||||||
useGetDisplaySettings,
|
useGetDisplaySettings,
|
||||||
useGetDisplayState,
|
useGetDisplayState,
|
||||||
useReleaseDisplay,
|
useReleaseDisplay,
|
||||||
useSetDisplayLayout,
|
useSetDisplayLayout,
|
||||||
useSetDisplaySettings,
|
useSetDisplaySettings,
|
||||||
|
useUpdateCustomPreset,
|
||||||
} from "@/api/gen/display/display";
|
} from "@/api/gen/display/display";
|
||||||
import type {
|
import type {
|
||||||
ApiDisplayInfo,
|
ApiDisplayInfo,
|
||||||
|
CustomPreset,
|
||||||
DisplayPolicy,
|
DisplayPolicy,
|
||||||
EffectivePolicy,
|
EffectivePolicy,
|
||||||
GameSession,
|
GameSession,
|
||||||
@@ -75,6 +80,7 @@ export const DisplaySection: FC = () => {
|
|||||||
draft={draft}
|
draft={draft}
|
||||||
setDraft={setDraft}
|
setDraft={setDraft}
|
||||||
presets={q.data.presets}
|
presets={q.data.presets}
|
||||||
|
customPresets={q.data.custom_presets}
|
||||||
apply={apply}
|
apply={apply}
|
||||||
busy={save.isPending}
|
busy={save.isPending}
|
||||||
error={apiErrorMessage(save.error)}
|
error={apiErrorMessage(save.error)}
|
||||||
@@ -109,10 +115,23 @@ const DisplayForm: FC<{
|
|||||||
draft: DisplayPolicy;
|
draft: DisplayPolicy;
|
||||||
setDraft: (p: DisplayPolicy) => void;
|
setDraft: (p: DisplayPolicy) => void;
|
||||||
presets: { id: string; summary: string; fields: EffectivePolicy }[];
|
presets: { id: string; summary: string; fields: EffectivePolicy }[];
|
||||||
|
customPresets: CustomPreset[];
|
||||||
apply: (p: DisplayPolicy) => void;
|
apply: (p: DisplayPolicy) => void;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> = ({ draft, setDraft, presets, apply, busy, error }) => {
|
}> = ({ draft, setDraft, presets, customPresets, apply, busy, error }) => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const createPreset = useCreateCustomPreset();
|
||||||
|
const updatePreset = useUpdateCustomPreset();
|
||||||
|
const deletePreset = useDeleteCustomPreset();
|
||||||
|
const invalidateSettings = () =>
|
||||||
|
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
|
||||||
|
const presetBusy =
|
||||||
|
createPreset.isPending || updatePreset.isPending || deletePreset.isPending;
|
||||||
|
const presetError = apiErrorMessage(
|
||||||
|
createPreset.error ?? updatePreset.error ?? deletePreset.error,
|
||||||
|
);
|
||||||
|
|
||||||
const preset: Preset = draft.preset ?? "custom";
|
const preset: Preset = draft.preset ?? "custom";
|
||||||
const isCustom = preset === "custom";
|
const isCustom = preset === "custom";
|
||||||
|
|
||||||
@@ -150,6 +169,56 @@ const DisplayForm: FC<{
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Applying a custom preset writes a `Custom` policy carrying its saved fields + game-session (the
|
||||||
|
// one axis a preset DOES set) — the host has no separate apply route (design/gamemode-and-…).
|
||||||
|
const applyCustomPreset = (p: CustomPreset) =>
|
||||||
|
apply({
|
||||||
|
version: 1,
|
||||||
|
preset: "custom",
|
||||||
|
...p.fields,
|
||||||
|
game_session: p.game_session ?? "auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
// A custom card is "current" when the in-force policy is a Custom one whose fields + game-session
|
||||||
|
// value-match this preset (there is no id on DisplayPolicy — match by value).
|
||||||
|
const customSelected = (p: CustomPreset): boolean =>
|
||||||
|
isCustom &&
|
||||||
|
(draft.game_session ?? "auto") === (p.game_session ?? "auto") &&
|
||||||
|
deepEqual(effective, p.fields);
|
||||||
|
const anyCustomSelected = customPresets.some(customSelected);
|
||||||
|
|
||||||
|
// Save the currently-in-force behavior (built-in OR hand-edited) as a new named preset.
|
||||||
|
const saveAsPreset = () => {
|
||||||
|
const name = prompt(m.display_preset_name())?.trim();
|
||||||
|
if (!name) return; // cancelled or empty
|
||||||
|
createPreset.mutate(
|
||||||
|
{
|
||||||
|
data: { name, fields: effective, game_session: draft.game_session ?? "auto" },
|
||||||
|
},
|
||||||
|
{ onSuccess: invalidateSettings },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const renamePreset = (p: CustomPreset) => {
|
||||||
|
const name = prompt(m.display_preset_name(), p.name)?.trim();
|
||||||
|
if (!name) return;
|
||||||
|
updatePreset.mutate(
|
||||||
|
{ id: p.id, data: { name, fields: p.fields, game_session: p.game_session ?? "auto" } },
|
||||||
|
{ onSuccess: invalidateSettings },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const updatePresetToCurrent = (p: CustomPreset) =>
|
||||||
|
updatePreset.mutate(
|
||||||
|
{
|
||||||
|
id: p.id,
|
||||||
|
data: { name: p.name, fields: effective, game_session: draft.game_session ?? "auto" },
|
||||||
|
},
|
||||||
|
{ onSuccess: invalidateSettings },
|
||||||
|
);
|
||||||
|
const removePreset = (p: CustomPreset) => {
|
||||||
|
if (!confirm(m.display_preset_delete_confirm())) return;
|
||||||
|
deletePreset.mutate({ id: p.id }, { onSuccess: invalidateSettings });
|
||||||
|
};
|
||||||
|
|
||||||
const ka = customFields.keep_alive;
|
const ka = customFields.keep_alive;
|
||||||
// The duration value, remembered across the Off/Keep toggle so switching back restores it.
|
// The duration value, remembered across the Off/Keep toggle so switching back restores it.
|
||||||
const [keepSecs, setKeepSecs] = useState(ka.mode === "duration" ? ka.seconds : 300);
|
const [keepSecs, setKeepSecs] = useState(ka.mode === "duration" ? ka.seconds : 300);
|
||||||
@@ -164,7 +233,9 @@ const DisplayForm: FC<{
|
|||||||
const p = presets.find((x) => x.id === id);
|
const p = presets.find((x) => x.id === id);
|
||||||
const fields = id === "custom" ? undefined : p?.fields;
|
const fields = id === "custom" ? undefined : p?.fields;
|
||||||
const summary = id === "custom" ? m.display_custom_desc() : p?.summary;
|
const summary = id === "custom" ? m.display_custom_desc() : p?.summary;
|
||||||
const selected = preset === id;
|
// The built-in "Custom" card is the hand-edit mode; when the active Custom policy
|
||||||
|
// value-matches a saved preset, that preset's card owns the "current" ring instead.
|
||||||
|
const selected = preset === id && !(id === "custom" && anyCustomSelected);
|
||||||
const soon = DISABLED_PRESETS.has(id);
|
const soon = DISABLED_PRESETS.has(id);
|
||||||
const disabled = busy || soon;
|
const disabled = busy || soon;
|
||||||
const pick = () => {
|
const pick = () => {
|
||||||
@@ -221,6 +292,44 @@ const DisplayForm: FC<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom presets — the operator's saved field-bundles, rendered like the built-ins but
|
||||||
|
editable/deletable, plus a "Save as preset" that captures the current effective behavior. */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Label className="text-base font-semibold">
|
||||||
|
{m.display_preset_custom_label()}
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy || presetBusy}
|
||||||
|
onClick={saveAsPreset}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
{m.display_preset_save_as()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{customPresets.length > 0 && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{customPresets.map((p) => (
|
||||||
|
<CustomPresetCard
|
||||||
|
key={p.id}
|
||||||
|
preset={p}
|
||||||
|
selected={customSelected(p)}
|
||||||
|
busy={busy || presetBusy}
|
||||||
|
onApply={() => applyCustomPreset(p)}
|
||||||
|
onRename={() => renamePreset(p)}
|
||||||
|
onUpdate={() => updatePresetToCurrent(p)}
|
||||||
|
onDelete={() => removePreset(p)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{presetError && (
|
||||||
|
<p className="text-sm text-amber-600 dark:text-amber-500">{presetError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Custom: every option by hand */}
|
{/* Custom: every option by hand */}
|
||||||
{isCustom && (
|
{isCustom && (
|
||||||
<div className="space-y-6 rounded-lg border p-5">
|
<div className="space-y-6 rounded-lg border p-5">
|
||||||
@@ -412,6 +521,95 @@ const Choice: FC<{
|
|||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One saved custom preset — the same interactive card as the built-ins (click to apply → writes a
|
||||||
|
* `Custom` policy carrying `preset.fields`), plus rename / update-to-current / delete affordances
|
||||||
|
* (each stops propagation so it doesn't also fire the card's apply). Field badges mirror the
|
||||||
|
* built-ins; the game-session badge shows only when it isn't the default `auto`.
|
||||||
|
*/
|
||||||
|
const CustomPresetCard: FC<{
|
||||||
|
preset: CustomPreset;
|
||||||
|
selected: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onApply: () => void;
|
||||||
|
onRename: () => void;
|
||||||
|
onUpdate: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}> = ({ preset, selected, busy, onApply, onRename, onUpdate, onDelete }) => {
|
||||||
|
const fields = preset.fields;
|
||||||
|
const stop = (fn: () => void) => (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!busy) fn();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
interactive
|
||||||
|
role="button"
|
||||||
|
tabIndex={busy ? -1 : 0}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-disabled={busy || undefined}
|
||||||
|
onClick={() => !busy && onApply()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!busy) onApply();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full flex-col p-4",
|
||||||
|
busy ? "cursor-not-allowed opacity-60" : "cursor-pointer",
|
||||||
|
selected && "ring-2 ring-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="min-w-0 truncate text-base font-semibold">{preset.name}</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{selected && <Badge variant="success">{m.display_preset_current()}</Badge>}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
title={m.display_preset_edit()}
|
||||||
|
aria-label={m.display_preset_edit()}
|
||||||
|
onClick={stop(onRename)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
title={m.display_preset_update()}
|
||||||
|
aria-label={m.display_preset_update()}
|
||||||
|
onClick={stop(onUpdate)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
title={m.display_preset_delete()}
|
||||||
|
aria-label={m.display_preset_delete()}
|
||||||
|
onClick={stop(onDelete)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto flex flex-wrap gap-1.5 pt-3">
|
||||||
|
<Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge>
|
||||||
|
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge>
|
||||||
|
<Badge variant="outline">{tr(CONFLICT_LABEL, fields.mode_conflict)}</Badge>
|
||||||
|
<Badge variant="outline">{tr(IDENTITY_LABEL, fields.identity)}</Badge>
|
||||||
|
{(preset.game_session ?? "auto") !== "auto" && (
|
||||||
|
<Badge variant="secondary">{tr(GAME_SESSION_LABEL, preset.game_session)}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The host's live/kept virtual displays, polled from `/display/state`, each with a Release button
|
* The host's live/kept virtual displays, polled from `/display/state`, each with a Release button
|
||||||
* for lingering/pinned ones (active displays can't be released — that's session control).
|
* for lingering/pinned ones (active displays can't be released — that's session control).
|
||||||
@@ -640,6 +838,19 @@ const GAME_SESSION_LABEL: Record<string, () => string> = {
|
|||||||
dedicated: m.display_game_session_dedicated,
|
dedicated: m.display_game_session_dedicated,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Structural equality for the value-match of a custom preset's fields against the effective policy
|
||||||
|
* (handles the nested `keep_alive` variants + `layout.positions` map; key order doesn't matter). */
|
||||||
|
const deepEqual = (a: unknown, b: unknown): boolean => {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
||||||
|
const ak = Object.keys(a as object);
|
||||||
|
const bk = Object.keys(b as object);
|
||||||
|
if (ak.length !== bk.length) return false;
|
||||||
|
return ak.every((k) =>
|
||||||
|
deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */
|
/** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */
|
||||||
const tr = (map: Record<string, () => string>, key: string | null | undefined): string => {
|
const tr = (map: Record<string, () => string>, key: string | null | undefined): string => {
|
||||||
const fn = key == null ? undefined : map[key];
|
const fn = key == null ? undefined : map[key];
|
||||||
|
|||||||
Reference in New Issue
Block a user