implement server name

This commit is contained in:
Matt Hill
2026-02-24 16:02:09 -07:00
parent d4e019c87b
commit d69e5b9f1a
19 changed files with 158 additions and 9232 deletions

View File

@@ -101,7 +101,7 @@ export class PortalComponent {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
readonly name = toSignal(this.patch.watch$('ui', 'name'))
readonly name = toSignal(this.patch.watch$('serverInfo', 'name'))
readonly update = toSignal(inject(OSService).updating$)
readonly bar = signal(true)

View File

@@ -7,7 +7,6 @@ import {
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import {
DialogService,
@@ -30,9 +29,7 @@ import { TuiAnimated } from '@taiga-ui/cdk'
import {
TuiAppearance,
TuiButton,
tuiFadeIn,
TuiIcon,
tuiScaleIn,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
@@ -46,7 +43,7 @@ import {
import { TuiCell, tuiCellOptionsProvider } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { filter, Subscription } from 'rxjs'
import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { OSService } from 'src/app/services/os.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -54,6 +51,7 @@ import { TitleDirective } from 'src/app/services/title.service'
import { SnekDirective } from './snek.directive'
import { UPDATE } from './update.component'
import { KeyboardSelectComponent } from './keyboard-select.component'
import { ServerNameDialog } from './server-name.dialog'
@Component({
template: `
@@ -98,25 +96,16 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.server" />
<span tuiTitle>
<strong>{{ 'Server Hostname' | i18n }}</strong>
<strong>{{ 'Server Name' | i18n }}</strong>
<span tuiSubtitle>
{{ server.hostname }}
{{ server.name }}
</span>
<span tuiSubtitle>{{ server.hostname }}.local</span>
</span>
<button tuiButton (click)="onHostname()">
<button tuiButton (click)="onName()">
{{ 'Change' | i18n }}
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.app-window" />
<span tuiTitle>
<strong>{{ 'Browser tab title' | i18n }}</strong>
<span tuiSubtitle>
{{ 'Customize the name appearing in your browser tab' | i18n }}
</span>
</span>
<button tuiButton (click)="onTitle()">{{ 'Change' | i18n }}</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.languages" />
<span tuiTitle>
@@ -276,7 +265,6 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
],
})
export default class SystemGeneralComponent {
private readonly title = inject(Title)
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
@@ -290,7 +278,6 @@ export default class SystemGeneralComponent {
count = 0
readonly server = toSignal(this.patch.watch$('serverInfo'))
readonly name = toSignal(this.patch.watch$('ui', 'name'))
readonly score = toSignal(this.patch.watch$('ui', 'snakeHighScore'))
readonly os = inject(OSService)
readonly i18nService = inject(i18nService)
@@ -360,33 +347,35 @@ export default class SystemGeneralComponent {
}
}
onHostname() {
const sub = this.dialog
.openPrompt<string>({
label: 'Server Hostname',
data: {
label: 'Hostname' as i18nKey,
message:
'This value will be used as your server hostname and mDNS address on the LAN. Only lowercase letters, numbers, and hyphens are allowed.',
placeholder: 'start9' as i18nKey,
required: true,
buttonText: 'Save',
initialValue: this.server()?.hostname || '',
pattern: '^[a-z0-9][a-z0-9-]*$',
patternError:
'Hostname must contain only lowercase letters, numbers, and hyphens, and cannot start with a hyphen.',
onName() {
const server = this.server()
if (!server) return
this.dialog
.openComponent<{ name: string; hostname: string } | null>(
new PolymorpheusComponent(ServerNameDialog, this.injector),
{
label: 'Server Name',
size: 's',
data: { initialName: server.name },
},
})
.subscribe(hostname => {
)
.pipe(
filter(
(result): result is { name: string; hostname: string } =>
result !== null,
),
)
.subscribe(result => {
if (this.win.location.hostname.endsWith('.local')) {
this.confirmHostnameChange(hostname, sub)
this.confirmNameChange(result)
} else {
this.saveHostname(hostname, sub)
this.saveName(result)
}
})
}
private confirmHostnameChange(hostname: string, promptSub: Subscription) {
private confirmNameChange(result: { name: string; hostname: string }) {
this.dialog
.openConfirm({
label: 'Warning',
@@ -398,18 +387,17 @@ export default class SystemGeneralComponent {
},
})
.pipe(filter(Boolean))
.subscribe(() => this.saveHostname(hostname, promptSub, true))
.subscribe(() => this.saveName(result, true))
}
private async saveHostname(
hostname: string,
promptSub: Subscription,
private async saveName(
{ name, hostname }: { name: string; hostname: string },
wasLocal = false,
) {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.setHostname({ hostname })
await this.api.setHostname({ name, hostname })
if (wasLocal) {
const { protocol, port } = this.win.location
@@ -432,40 +420,9 @@ export default class SystemGeneralComponent {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
promptSub.unsubscribe()
}
}
onTitle() {
const sub = this.dialog
.openPrompt<string>({
label: 'Browser tab title',
data: {
label: 'Device Name',
message:
'This value will be displayed as the title of your browser tab.',
placeholder: 'StartOS' as i18nKey,
required: false,
buttonText: 'Save',
initialValue: this.name(),
},
})
.subscribe(async name => {
const loader = this.loader.open('Saving').subscribe()
const title = `${name || 'StartOS'}${this.i18n.transform('System')}`
try {
await this.api.setDbValue(['name'], name || null)
this.title.setTitle(title)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
sub.unsubscribe()
}
})
}
async onRepair() {
this.dialog
.openConfirm({

View File

@@ -0,0 +1,63 @@
import { Component } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { i18nPipe, normalizeHostname } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiTextfield } from '@taiga-ui/core'
import { injectContext } from '@taiga-ui/polymorpheus'
@Component({
template: `
<tui-textfield>
<label tuiLabel>{{ 'Server Name' | i18n }}</label>
<input tuiTextfield [(ngModel)]="name" />
</tui-textfield>
@if (name.trim()) {
<p class="hostname-preview">{{ normalizeHostname(name) }}.local</p>
}
<footer>
<button tuiButton appearance="secondary" (click)="cancel()">
{{ 'Cancel' | i18n }}
</button>
<button tuiButton [disabled]="!name.trim()" (click)="confirm()">
{{ 'Save' | i18n }}
</button>
</footer>
`,
styles: `
.hostname-preview {
color: var(--tui-text-secondary);
font: var(--tui-font-text-s);
margin-top: 0.25rem;
}
footer {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
`,
imports: [FormsModule, TuiButton, TuiTextfield, i18nPipe],
})
export class ServerNameDialog {
private readonly context =
injectContext<
TuiDialogContext<
{ name: string; hostname: string } | null,
{ initialName: string }
>
>()
name = this.context.data.initialName
readonly normalizeHostname = normalizeHostname
cancel() {
this.context.completeWith(null)
}
confirm() {
const name = this.name.trim()
this.context.completeWith({
name,
hostname: normalizeHostname(name),
})
}
}

View File

@@ -183,6 +183,7 @@ import UpdatesComponent from './updates.component'
&:last-child {
white-space: nowrap;
text-align: right;
}
&[colspan]:only-child {

View File

@@ -121,7 +121,7 @@ export abstract class ApiService {
abstract toggleKiosk(enable: boolean): Promise<null>
abstract setHostname(params: { hostname: string }): Promise<null>
abstract setHostname(params: T.SetServerHostnameParams): Promise<null>
abstract setKeyboard(params: FullKeyboard): Promise<null>

View File

@@ -255,7 +255,7 @@ export class LiveApiService extends ApiService {
})
}
async setHostname(params: { hostname: string }): Promise<null> {
async setHostname(params: T.SetServerHostnameParams): Promise<null> {
return this.rpcRequest({ method: 'server.set-hostname', params })
}

View File

@@ -447,10 +447,15 @@ export class MockApiService extends ApiService {
return null
}
async setHostname(params: { hostname: string }): Promise<null> {
async setHostname(params: T.SetServerHostnameParams): Promise<null> {
await pauseFor(1000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/name',
value: params.name,
},
{
op: PatchOp.REPLACE,
path: '/serverInfo/hostname',

View File

@@ -232,6 +232,7 @@ export const mockPatchData: DataModel = {
shuttingDown: false,
backupProgress: null,
},
name: 'Random Words',
hostname: 'random-words',
pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
caFingerprint: '63:2B:11:99:44:40:17:DF:37:FC:C3:DF:0F:3D:15',

View File

@@ -13,7 +13,7 @@ export async function titleResolver({
let route = inject(i18nPipe).transform(data['title'])
const patch = inject<PatchDB<DataModel>>(PatchDB)
const title = await firstValueFrom(patch.watch$('ui', 'name'))
const title = await firstValueFrom(patch.watch$('serverInfo', 'name'))
const id = params['pkgId']
if (id) {