Add configurable grid branding
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="app">
|
<main class="app">
|
||||||
<h1>Chat Grid</h1>
|
<h1 id="gridTitle">Chat Grid</h1>
|
||||||
<div id="connectionStatus" role="status" aria-live="polite" aria-atomic="true"></div>
|
<div id="connectionStatus" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
<section id="loginView" class="auth-panel">
|
<section id="loginView" class="auth-panel">
|
||||||
<h2>Login</h2>
|
<h2>Login</h2>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Maintainer-controlled web client version metadata.
|
// Maintainer-controlled web client version metadata.
|
||||||
window.CHGRID_RELEASE_VERSION = "0.1.0";
|
window.CHGRID_RELEASE_VERSION = "0.1.0";
|
||||||
window.CHGRID_BUILD_REVISION = "R346";
|
window.CHGRID_BUILD_REVISION = "R347";
|
||||||
window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`;
|
window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`;
|
||||||
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Dom = {
|
type Dom = {
|
||||||
|
gridTitle: HTMLElement;
|
||||||
connectionStatus: HTMLElement;
|
connectionStatus: HTMLElement;
|
||||||
appVersion: HTMLElement;
|
appVersion: HTMLElement;
|
||||||
loginView: HTMLElement;
|
loginView: HTMLElement;
|
||||||
@@ -135,6 +136,7 @@ type Dom = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dom: Dom = {
|
const dom: Dom = {
|
||||||
|
gridTitle: requiredById('gridTitle'),
|
||||||
connectionStatus: requiredById('connectionStatus'),
|
connectionStatus: requiredById('connectionStatus'),
|
||||||
appVersion: requiredById('appVersion'),
|
appVersion: requiredById('appVersion'),
|
||||||
loginView: requiredById('loginView'),
|
loginView: requiredById('loginView'),
|
||||||
@@ -225,6 +227,9 @@ const APP_VERSION = String(
|
|||||||
dom.appVersion.textContent = APP_VERSION
|
dom.appVersion.textContent = APP_VERSION
|
||||||
? `Another AI experiment with Jage. Version ${APP_VERSION}`
|
? `Another AI experiment with Jage. Version ${APP_VERSION}`
|
||||||
: 'Another AI experiment with Jage. Version unknown';
|
: 'Another AI experiment with Jage. Version unknown';
|
||||||
|
const DEFAULT_GRID_NAME = 'Chat Grid';
|
||||||
|
const DEFAULT_WELCOME_MESSAGE =
|
||||||
|
'Welcome to the Chat Grid, your immersive audio playground. Configure your audio, then Log in or register to join the grid.';
|
||||||
const APP_BASE_URL = import.meta.env.BASE_URL || '/';
|
const APP_BASE_URL = import.meta.env.BASE_URL || '/';
|
||||||
/** Resolves an app-relative path against the configured Vite base path. */
|
/** Resolves an app-relative path against the configured Vite base path. */
|
||||||
function withBase(path: string): string {
|
function withBase(path: string): string {
|
||||||
@@ -259,6 +264,8 @@ let lastFocusedElement: Element | null = null;
|
|||||||
let lastAnnouncementText = '';
|
let lastAnnouncementText = '';
|
||||||
let lastAnnouncementAt = 0;
|
let lastAnnouncementAt = 0;
|
||||||
let outputMode = settings.loadOutputMode();
|
let outputMode = settings.loadOutputMode();
|
||||||
|
let activeGridName = DEFAULT_GRID_NAME;
|
||||||
|
let activeWelcomeMessage = DEFAULT_WELCOME_MESSAGE;
|
||||||
const messageBuffer: string[] = [];
|
const messageBuffer: string[] = [];
|
||||||
let messageCursor = -1;
|
let messageCursor = -1;
|
||||||
const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig);
|
const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig);
|
||||||
@@ -360,6 +367,17 @@ void loadHelp();
|
|||||||
void itemBehaviorRegistry.initialize();
|
void itemBehaviorRegistry.initialize();
|
||||||
void loadChangelog();
|
void loadChangelog();
|
||||||
|
|
||||||
|
function applyGridBranding(gridName: string | null | undefined, welcomeMessage: string | null | undefined): void {
|
||||||
|
const nextGridName = String(gridName ?? '').trim() || DEFAULT_GRID_NAME;
|
||||||
|
const nextWelcomeMessage = String(welcomeMessage ?? '').trim() || DEFAULT_WELCOME_MESSAGE;
|
||||||
|
activeGridName = nextGridName;
|
||||||
|
activeWelcomeMessage = nextWelcomeMessage;
|
||||||
|
document.title = nextGridName;
|
||||||
|
dom.gridTitle.textContent = nextGridName;
|
||||||
|
dom.focusGridButton.textContent = nextGridName;
|
||||||
|
dom.canvas.setAttribute('aria-label', `${nextGridName}, press question mark for help.`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetches a required DOM element and casts it to the requested element type. */
|
/** Fetches a required DOM element and casts it to the requested element type. */
|
||||||
function requiredById<T extends HTMLElement>(id: string): T {
|
function requiredById<T extends HTMLElement>(id: string): T {
|
||||||
const found = document.getElementById(id);
|
const found = document.getElementById(id);
|
||||||
@@ -405,6 +423,7 @@ const adminController = createAdminController({
|
|||||||
signalingSend: (message) => signaling.send(message),
|
signalingSend: (message) => signaling.send(message),
|
||||||
announceMenuEntry,
|
announceMenuEntry,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
|
getGridName: () => activeGridName,
|
||||||
sfxUiBlip: () => audio.sfxUiBlip(),
|
sfxUiBlip: () => audio.sfxUiBlip(),
|
||||||
sfxUiCancel: () => audio.sfxUiCancel(),
|
sfxUiCancel: () => audio.sfxUiCancel(),
|
||||||
applyTextInputEdit,
|
applyTextInputEdit,
|
||||||
@@ -1345,6 +1364,7 @@ function sendAuthRequest(): void {
|
|||||||
|
|
||||||
/** Handles server auth-required prompts prior to world welcome. */
|
/** Handles server auth-required prompts prior to world welcome. */
|
||||||
function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void {
|
function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void {
|
||||||
|
applyGridBranding(message.gridName, message.welcomeMessage);
|
||||||
authController.handleAuthRequired(message);
|
authController.handleAuthRequired(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1563,6 +1583,7 @@ async function onSignalingMessage(message: IncomingMessage): Promise<void> {
|
|||||||
let connectedAnnouncement: string | null = null;
|
let connectedAnnouncement: string | null = null;
|
||||||
let playSelfLoginSound = false;
|
let playSelfLoginSound = false;
|
||||||
if (message.type === 'welcome') {
|
if (message.type === 'welcome') {
|
||||||
|
applyGridBranding(message.serverInfo?.gridName, message.serverInfo?.welcomeMessage);
|
||||||
const uiAdminActions =
|
const uiAdminActions =
|
||||||
(message.uiDefinitions as { adminMenu?: { actions?: Array<{ id: string; label: string }> } } | undefined)?.adminMenu?.actions ??
|
(message.uiDefinitions as { adminMenu?: { actions?: Array<{ id: string; label: string }> } } | undefined)?.adminMenu?.actions ??
|
||||||
message.auth?.adminMenuActions;
|
message.auth?.adminMenuActions;
|
||||||
@@ -2676,5 +2697,5 @@ updateDeviceSummary();
|
|||||||
setConnectionStatus(
|
setConnectionStatus(
|
||||||
isVersionReloadedSession()
|
isVersionReloadedSession()
|
||||||
? 'Client updated, please reconnect.'
|
? 'Client updated, please reconnect.'
|
||||||
: 'Welcome to the Chat Grid, your immersive audio playground. Configure your audio, then Log in or register to join the grid.',
|
: activeWelcomeMessage,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export const welcomeMessageSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
instanceId: z.string(),
|
instanceId: z.string(),
|
||||||
version: z.string().optional(),
|
version: z.string().optional(),
|
||||||
|
gridName: z.string().optional(),
|
||||||
|
welcomeMessage: z.string().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
auth: z
|
auth: z
|
||||||
@@ -136,6 +138,8 @@ export const welcomeMessageSchema = z.object({
|
|||||||
export const authRequiredSchema = z.object({
|
export const authRequiredSchema = z.object({
|
||||||
type: z.literal('auth_required'),
|
type: z.literal('auth_required'),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
|
gridName: z.string().optional(),
|
||||||
|
welcomeMessage: z.string().optional(),
|
||||||
authPolicy: z
|
authPolicy: z
|
||||||
.object({
|
.object({
|
||||||
usernameMinLength: z.number().int().positive(),
|
usernameMinLength: z.number().int().positive(),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type UiBindingsDeps = {
|
|||||||
openSettings: () => void;
|
openSettings: () => void;
|
||||||
closeSettings: () => void;
|
closeSettings: () => void;
|
||||||
updateStatus: (message: string) => void;
|
updateStatus: (message: string) => void;
|
||||||
|
getGridName: () => string;
|
||||||
sfxUiBlip: () => void;
|
sfxUiBlip: () => void;
|
||||||
setupLocalMedia: (audioDeviceId: string) => Promise<void>;
|
setupLocalMedia: (audioDeviceId: string) => Promise<void>;
|
||||||
setPreferredInput: (id: string, name: string) => void;
|
setPreferredInput: (id: string, name: string) => void;
|
||||||
@@ -46,7 +47,7 @@ export function setupUiHandlers(deps: UiBindingsDeps): void {
|
|||||||
|
|
||||||
deps.dom.focusGridButton.addEventListener('click', () => {
|
deps.dom.focusGridButton.addEventListener('click', () => {
|
||||||
deps.dom.canvas.focus();
|
deps.dom.canvas.focus();
|
||||||
deps.updateStatus('Chat Grid focused.');
|
deps.updateStatus(`${deps.getGridName()} focused.`);
|
||||||
deps.sfxUiBlip();
|
deps.sfxUiBlip();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
|||||||
- `permissions`
|
- `permissions`
|
||||||
- `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`)
|
- `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`)
|
||||||
- `auth_required.authPolicy`: server auth limits advertised before login/register submit.
|
- `auth_required.authPolicy`: server auth limits advertised before login/register submit.
|
||||||
|
- `auth_required.gridName` / `auth_required.welcomeMessage`: server-owned pre-login branding values.
|
||||||
- `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses.
|
- `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses.
|
||||||
- `auth_result.sessionToken` is used by the client to call the instance-scoped HTTP endpoint `GET <base_path>auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`) so the server can issue an instance-scoped `HttpOnly` session cookie.
|
- `auth_result.sessionToken` is used by the client to call the instance-scoped HTTP endpoint `GET <base_path>auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`) so the server can issue an instance-scoped `HttpOnly` session cookie.
|
||||||
- `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing.
|
- `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing.
|
||||||
@@ -104,6 +105,8 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
|||||||
- `welcome.serverInfo`: server process identity/version metadata:
|
- `welcome.serverInfo`: server process identity/version metadata:
|
||||||
- `instanceId`: unique id generated at server startup
|
- `instanceId`: unique id generated at server startup
|
||||||
- `version`: server package version (or `unknown` fallback)
|
- `version`: server package version (or `unknown` fallback)
|
||||||
|
- `gridName`: server-owned user-facing grid name
|
||||||
|
- `welcomeMessage`: server-owned pre-login welcome string
|
||||||
- `welcome.uiDefinitions`: server-provided item UI definitions:
|
- `welcome.uiDefinitions`: server-provided item UI definitions:
|
||||||
- `itemTypeOrder`: add-item menu order
|
- `itemTypeOrder`: add-item menu order
|
||||||
- `itemTypes[].tooltip`: item-level tooltip/help text
|
- `itemTypes[].tooltip`: item-level tooltip/help text
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
3. Client connects signaling websocket from the configured app origin.
|
3. Client connects signaling websocket from the configured app origin.
|
||||||
4. Server accepts the socket only on the configured instance websocket path and when the browser `Origin` matches `CHGRID_HOST_ORIGIN`, then attempts cookie-based session resume from the instance-scoped websocket handshake cookie.
|
4. Server accepts the socket only on the configured instance websocket path and when the browser `Origin` matches `CHGRID_HOST_ORIGIN`, then attempts cookie-based session resume from the instance-scoped websocket handshake cookie.
|
||||||
5. If resume does not authenticate, server sends `auth_required`.
|
5. If resume does not authenticate, server sends `auth_required`.
|
||||||
|
- includes `gridName` and `welcomeMessage` for pre-login branding.
|
||||||
- includes `authPolicy` limits for username/password.
|
- includes `authPolicy` limits for username/password.
|
||||||
6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).
|
6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).
|
||||||
7. Server sends `auth_result`.
|
7. Server sends `auth_result`.
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
- applies `welcome.worldConfig.movementTickMs` as movement pacing guidance
|
- applies `welcome.worldConfig.movementTickMs` as movement pacing guidance
|
||||||
- applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity
|
- applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity
|
||||||
- uses `welcome.player` as authoritative starting position (restored from server-side account state when available)
|
- uses `welcome.player` as authoritative starting position (restored from server-side account state when available)
|
||||||
- records `welcome.serverInfo` (`instanceId`, `version`) for restart detection
|
- records `welcome.serverInfo` (`instanceId`, `version`, `gridName`, `welcomeMessage`) for restart detection and client branding
|
||||||
- if `welcome.serverInfo.version` differs from running client version, auto-reloads the page
|
- if `welcome.serverInfo.version` differs from running client version, auto-reloads the page
|
||||||
- applies `welcome.uiDefinitions` for item menus/properties/options, server-backed command metadata, item-management metadata, and admin menu labels/order
|
- applies `welcome.uiDefinitions` for item menus/properties/options, server-backed command metadata, item-management metadata, and admin menu labels/order
|
||||||
- sends initial `update_position` echo from server-assigned starting tile
|
- sends initial `update_position` echo from server-assigned starting tile
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ class ServerConfigSection(BaseModel):
|
|||||||
bind_ip: str = "127.0.0.1"
|
bind_ip: str = "127.0.0.1"
|
||||||
port: int = 8765
|
port: int = 8765
|
||||||
base_path: str = "/"
|
base_path: str = "/"
|
||||||
|
grid_name: str = "Chat Grid"
|
||||||
|
welcome_message: str = (
|
||||||
|
"Welcome to the Chat Grid, your immersive audio playground. "
|
||||||
|
"Configure your audio, then Log in or register to join the grid."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NetworkConfigSection(BaseModel):
|
class NetworkConfigSection(BaseModel):
|
||||||
|
|||||||
@@ -242,6 +242,8 @@ class AuthRequiredPacket(BasePacket):
|
|||||||
type: Literal["auth_required"]
|
type: Literal["auth_required"]
|
||||||
message: str
|
message: str
|
||||||
authPolicy: dict | None = None
|
authPolicy: dict | None = None
|
||||||
|
gridName: str | None = None
|
||||||
|
welcomeMessage: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AuthResultPacket(BasePacket):
|
class AuthResultPacket(BasePacket):
|
||||||
|
|||||||
@@ -165,6 +165,11 @@ class SignalingServer:
|
|||||||
state_save_max_delay_ms: int = 1000,
|
state_save_max_delay_ms: int = 1000,
|
||||||
host_origin: str | None = None,
|
host_origin: str | None = None,
|
||||||
base_path: str = "/",
|
base_path: str = "/",
|
||||||
|
grid_name: str = "Chat Grid",
|
||||||
|
welcome_message: str = (
|
||||||
|
"Welcome to the Chat Grid, your immersive audio playground. "
|
||||||
|
"Configure your audio, then Log in or register to join the grid."
|
||||||
|
),
|
||||||
):
|
):
|
||||||
"""Initialize runtime state, TLS context, and item service."""
|
"""Initialize runtime state, TLS context, and item service."""
|
||||||
|
|
||||||
@@ -194,6 +199,11 @@ class SignalingServer:
|
|||||||
self.server_version = self._resolve_server_version()
|
self.server_version = self._resolve_server_version()
|
||||||
self.host_origin = normalize_origin(host_origin, field_name="host origin") if host_origin else None
|
self.host_origin = normalize_origin(host_origin, field_name="host origin") if host_origin else None
|
||||||
self.base_path = self._normalize_base_path(base_path)
|
self.base_path = self._normalize_base_path(base_path)
|
||||||
|
self.grid_name = str(grid_name).strip() or "Chat Grid"
|
||||||
|
self.welcome_message = (
|
||||||
|
str(welcome_message).strip()
|
||||||
|
or "Welcome to the Chat Grid, your immersive audio playground. Configure your audio, then Log in or register to join the grid."
|
||||||
|
)
|
||||||
self.auth_session_cookie_name = self._session_cookie_name_for_base_path(self.base_path)
|
self.auth_session_cookie_name = self._session_cookie_name_for_base_path(self.base_path)
|
||||||
self.websocket_path = self._base_path_join(WEBSOCKET_PATH)
|
self.websocket_path = self._base_path_join(WEBSOCKET_PATH)
|
||||||
self.auth_session_cookie_set_path = self._base_path_join(AUTH_SESSION_COOKIE_SET_PATH)
|
self.auth_session_cookie_set_path = self._base_path_join(AUTH_SESSION_COOKIE_SET_PATH)
|
||||||
@@ -1493,6 +1503,8 @@ class SignalingServer:
|
|||||||
type="auth_required",
|
type="auth_required",
|
||||||
message="Authentication required.",
|
message="Authentication required.",
|
||||||
authPolicy=self._auth_policy(),
|
authPolicy=self._auth_policy(),
|
||||||
|
gridName=self.grid_name,
|
||||||
|
welcomeMessage=self.welcome_message,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
async for raw_message in websocket:
|
async for raw_message in websocket:
|
||||||
@@ -1549,7 +1561,12 @@ class SignalingServer:
|
|||||||
"movementMaxStepsPerTick": self.movement_max_steps_per_tick,
|
"movementMaxStepsPerTick": self.movement_max_steps_per_tick,
|
||||||
},
|
},
|
||||||
uiDefinitions=self._build_ui_definitions(client),
|
uiDefinitions=self._build_ui_definitions(client),
|
||||||
serverInfo={"instanceId": self.instance_id, "version": self.server_version},
|
serverInfo={
|
||||||
|
"instanceId": self.instance_id,
|
||||||
|
"version": self.server_version,
|
||||||
|
"gridName": self.grid_name,
|
||||||
|
"welcomeMessage": self.welcome_message,
|
||||||
|
},
|
||||||
auth={
|
auth={
|
||||||
"authenticated": client.authenticated,
|
"authenticated": client.authenticated,
|
||||||
"userId": client.user_id,
|
"userId": client.user_id,
|
||||||
@@ -3311,5 +3328,7 @@ def run() -> None:
|
|||||||
state_save_max_delay_ms=config.storage.state_save_max_delay_ms,
|
state_save_max_delay_ms=config.storage.state_save_max_delay_ms,
|
||||||
host_origin=host_origin,
|
host_origin=host_origin,
|
||||||
base_path=config.server.base_path,
|
base_path=config.server.base_path,
|
||||||
|
grid_name=config.server.grid_name,
|
||||||
|
welcome_message=config.server.welcome_message,
|
||||||
)
|
)
|
||||||
asyncio.run(server.start())
|
asyncio.run(server.start())
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ bind_ip = "127.0.0.1"
|
|||||||
port = 8765
|
port = 8765
|
||||||
# Public base path for this grid instance. Examples: "/", "/chgrid/", "/ttgrid/".
|
# Public base path for this grid instance. Examples: "/", "/chgrid/", "/ttgrid/".
|
||||||
base_path = "/"
|
base_path = "/"
|
||||||
|
# User-facing grid name shown in the web client.
|
||||||
|
grid_name = "Chat Grid"
|
||||||
|
# User-facing pre-connect welcome text shown on the home screen.
|
||||||
|
welcome_message = "Welcome to the Chat Grid, your immersive audio playground. Configure your audio, then Log in or register to join the grid."
|
||||||
|
|
||||||
[network]
|
[network]
|
||||||
# Maximum inbound websocket message size in bytes.
|
# Maximum inbound websocket message size in bytes.
|
||||||
|
|||||||
@@ -59,3 +59,20 @@ base_path = "/ttgrid/"
|
|||||||
)
|
)
|
||||||
cfg = load_config(config_path)
|
cfg = load_config(config_path)
|
||||||
assert cfg.server.base_path == "/ttgrid/"
|
assert cfg.server.base_path == "/ttgrid/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_reads_grid_name_and_welcome_message(tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
config_path.write_text(
|
||||||
|
"""
|
||||||
|
[network]
|
||||||
|
allow_insecure_ws = true
|
||||||
|
|
||||||
|
[server]
|
||||||
|
grid_name = "TT Grid"
|
||||||
|
welcome_message = "Welcome to TT Grid."
|
||||||
|
""".strip()
|
||||||
|
)
|
||||||
|
cfg = load_config(config_path)
|
||||||
|
assert cfg.server.grid_name == "TT Grid"
|
||||||
|
assert cfg.server.welcome_message == "Welcome to TT Grid."
|
||||||
|
|||||||
Reference in New Issue
Block a user