Merge branch 'feat/preferred-port-design' of github.com:Start9Labs/start-os into feat/preferred-port-design

This commit is contained in:
Aiden McClelland
2026-02-24 16:06:21 -07:00
19 changed files with 158 additions and 9232 deletions

View File

@@ -176,8 +176,6 @@ pub struct AttachParams {
pub guid: InternedString,
#[ts(optional)]
pub kiosk: Option<bool>,
pub name: Option<InternedString>,
pub hostname: Option<InternedString>,
}
#[instrument(skip_all)]
@@ -187,8 +185,6 @@ pub async fn attach(
password,
guid: disk_guid,
kiosk,
name,
hostname,
}: AttachParams,
) -> Result<SetupProgress, Error> {
let setup_ctx = ctx.clone();
@@ -242,10 +238,8 @@ pub async fn attach(
}
disk_phase.complete();
let hostname = ServerHostnameInfo::new_opt(name, hostname)?;
let (account, net_ctrl) =
setup_init(&setup_ctx, password, kiosk, hostname, init_phases).await?;
setup_init(&setup_ctx, password, kiosk, None, init_phases).await?;
let rpc_ctx = RpcContext::init(
&setup_ctx.webserver,

View File

@@ -5,6 +5,4 @@ export type AttachParams = {
password: EncryptedWire | null
guid: string
kiosk?: boolean
name: string | null
hostname: string | null
}

View File

@@ -10,15 +10,14 @@ import {
} from '@angular/forms'
import {
ErrorService,
generateHostname,
i18nPipe,
LoadingService,
normalizeHostname,
} from '@start9labs/shared'
import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
import {
TuiButton,
TuiError,
TuiHint,
TuiIcon,
TuiTextfield,
TuiTitle,
@@ -26,7 +25,6 @@ import {
import {
TuiFieldErrorPipe,
TuiPassword,
TuiTooltip,
tuiValidationErrorsProvider,
} from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
@@ -48,29 +46,16 @@ import { StateService } from '../services/state.service'
<form [formGroup]="form" (ngSubmit)="submit()">
@if (isFresh) {
<tui-textfield>
<label tuiLabel>{{ 'Server Hostname' | i18n }}</label>
<input tuiTextfield tuiAutoFocus formControlName="hostname" />
<span class="local-suffix">.local</span>
<button
tuiIconButton
type="button"
appearance="icon"
iconStart="@tui.refresh-cw"
size="xs"
[tuiHint]="'Randomize' | i18n"
(click)="randomizeHostname()"
></button>
<tui-icon
[tuiTooltip]="
'This value will be used as your server hostname and mDNS address on the LAN. Only lowercase letters, numbers, and hyphens are allowed.'
| i18n
"
/>
<label tuiLabel>{{ 'Server Name' | i18n }}</label>
<input tuiTextfield tuiAutoFocus formControlName="name" />
</tui-textfield>
<tui-error
formControlName="hostname"
formControlName="name"
[error]="[] | tuiFieldError | async"
/>
@if (form.controls.name.value?.trim()) {
<p class="hostname-preview">{{ derivedHostname }}.local</p>
}
}
<tui-textfield [style.margin-top.rem]="isFresh ? 1 : 0">
@@ -134,8 +119,10 @@ import { StateService } from '../services/state.service'
</section>
`,
styles: `
.local-suffix {
.hostname-preview {
color: var(--tui-text-secondary);
font: var(--tui-font-text-s);
margin-top: 0.25rem;
}
footer {
@@ -160,8 +147,6 @@ import { StateService } from '../services/state.service'
TuiMapperPipe,
TuiHeader,
TuiTitle,
TuiHint,
TuiTooltip,
i18nPipe,
],
providers: [
@@ -170,7 +155,6 @@ import { StateService } from '../services/state.service'
minlength: 'Must be 12 characters or greater',
maxlength: 'Must be 64 character or less',
match: 'Passwords do not match',
pattern: 'Only lowercase letters, numbers, and hyphens allowed',
}),
],
})
@@ -181,7 +165,7 @@ export default class PasswordPage {
private readonly stateService = inject(StateService)
private readonly i18n = inject(i18nPipe)
// Fresh install requires password and hostname
// Fresh install requires password and name
readonly isFresh = this.stateService.setupType === 'fresh'
readonly form = new FormGroup({
@@ -191,10 +175,7 @@ export default class PasswordPage {
Validators.maxLength(64),
]),
confirm: new FormControl(''),
hostname: new FormControl(generateHostname(), [
Validators.required,
Validators.pattern(/^[a-z0-9][a-z0-9-]*$/),
]),
name: new FormControl('', [Validators.required]),
})
readonly validator = (value: string) => (control: AbstractControl) =>
@@ -202,8 +183,8 @@ export default class PasswordPage {
? null
: { match: this.i18n.transform('Passwords do not match') }
randomizeHostname() {
this.form.controls.hostname.setValue(generateHostname())
get derivedHostname(): string {
return normalizeHostname(this.form.controls.name.value || '')
}
async skip() {
@@ -217,14 +198,15 @@ export default class PasswordPage {
private async executeSetup(password: string | null) {
const loader = this.loader.open('Starting setup').subscribe()
const hostname = this.form.controls.hostname.value || generateHostname()
const name = this.form.controls.name.value || ''
const hostname = normalizeHostname(name)
try {
if (this.stateService.setupType === 'attach') {
await this.stateService.attachDrive(password, hostname)
await this.stateService.attachDrive(password)
} else {
// fresh, restore, or transfer - all use execute
await this.stateService.executeSetup(password, hostname)
await this.stateService.executeSetup(password, name, hostname)
}
await this.router.navigate(['/loading'])

View File

@@ -48,11 +48,10 @@ export class StateService {
/**
* Called for attach flow (existing data drive)
*/
async attachDrive(password: string | null, hostname: string): Promise<void> {
async attachDrive(password: string | null): Promise<void> {
await this.api.attach({
guid: this.dataDriveGuid,
password: password ? await this.api.encrypt(password) : null,
hostname,
})
}
@@ -60,7 +59,11 @@ export class StateService {
* Called for fresh, restore, and transfer flows
* Password is required for fresh, optional for restore/transfer
*/
async executeSetup(password: string | null, hostname: string): Promise<void> {
async executeSetup(
password: string | null,
name: string,
hostname: string,
): Promise<void> {
let recoverySource: T.RecoverySource<T.EncryptedWire> | null = null
if (this.recoverySource) {
@@ -81,6 +84,7 @@ export class StateService {
guid: this.dataDriveGuid,
// @ts-expect-error TODO: backend should make password optional for restore/transfer
password: password ? await this.api.encrypt(password) : null,
name,
hostname,
recoverySource,
})

View File

@@ -691,12 +691,9 @@ export default {
755: 'Schnittstelle(n)',
756: 'Keine Portweiterleitungsregeln',
757: 'Portweiterleitungsregeln am Gateway erforderlich',
758: 'Server-Hostname',
760: 'Dieser Wert wird als Hostname Ihres Servers und mDNS-Adresse im LAN verwendet. Nur Kleinbuchstaben, Zahlen und Bindestriche sind erlaubt.',
761: 'Zufällig generieren',
762: 'Nur Kleinbuchstaben, Zahlen und Bindestriche erlaubt',
763: 'Sie sind derzeit über Ihre .local-Adresse verbunden. Das Ändern des Hostnamens erfordert einen Wechsel zur neuen .local-Adresse.',
764: 'Hostname geändert',
765: 'Neue Adresse öffnen',
766: 'Ihr Server ist jetzt erreichbar unter',
767: 'Servername',
} satisfies i18n

View File

@@ -691,12 +691,9 @@ export const ENGLISH: Record<string, number> = {
'Interface(s)': 755,
'No port forwarding rules': 756,
'Port forwarding rules required on gateway': 757,
'Server Hostname': 758,
'This value will be used as your server hostname and mDNS address on the LAN. Only lowercase letters, numbers, and hyphens are allowed.': 760,
'Randomize': 761,
'Only lowercase letters, numbers, and hyphens allowed': 762,
'You are currently connected via your .local address. Changing the hostname will require you to switch to the new .local address.': 763,
'Hostname Changed': 764,
'Open new address': 765,
'Your server is now reachable at': 766,
'Server Name': 767,
}

View File

@@ -691,12 +691,9 @@ export default {
755: 'Interfaz/Interfaces',
756: 'Sin reglas de redirección de puertos',
757: 'Reglas de redirección de puertos requeridas en la puerta de enlace',
758: 'Nombre de host del servidor',
760: 'Este valor se usará como el nombre de host de su servidor y la dirección mDNS en la LAN. Solo se permiten letras minúsculas, números y guiones.',
761: 'Aleatorizar',
762: 'Solo se permiten letras minúsculas, números y guiones',
763: 'Actualmente está conectado a través de su dirección .local. Cambiar el nombre de host requerirá que cambie a la nueva dirección .local.',
764: 'Nombre de host cambiado',
765: 'Abrir nueva dirección',
766: 'Su servidor ahora es accesible en',
767: 'Nombre del servidor',
} satisfies i18n

View File

@@ -691,12 +691,9 @@ export default {
755: 'Interface(s)',
756: 'Aucune règle de redirection de port',
757: 'Règles de redirection de ports requises sur la passerelle',
758: "Nom d'hôte du serveur",
760: "Cette valeur sera utilisée comme nom d'hôte de votre serveur et adresse mDNS sur le LAN. Seules les lettres minuscules, les chiffres et les tirets sont autorisés.",
761: 'Générer aléatoirement',
762: 'Seules les lettres minuscules, les chiffres et les tirets sont autorisés',
763: "Vous êtes actuellement connecté via votre adresse .local. Changer le nom d'hôte nécessitera de passer à la nouvelle adresse .local.",
764: "Nom d'hôte modifié",
765: 'Ouvrir la nouvelle adresse',
766: 'Votre serveur est maintenant accessible à',
767: 'Nom du serveur',
} satisfies i18n

View File

@@ -691,12 +691,9 @@ export default {
755: 'Interfejs(y)',
756: 'Brak reguł przekierowania portów',
757: 'Reguły przekierowania portów wymagane na bramce',
758: 'Nazwa hosta serwera',
760: 'Ta wartość będzie używana jako nazwa hosta serwera i adres mDNS w sieci LAN. Dozwolone są tylko małe litery, cyfry i myślniki.',
761: 'Losuj',
762: 'Dozwolone są tylko małe litery, cyfry i myślniki',
763: 'Jesteś obecnie połączony przez adres .local. Zmiana nazwy hosta będzie wymagać przełączenia na nowy adres .local.',
764: 'Nazwa hosta zmieniona',
765: 'Otwórz nowy adres',
766: 'Twój serwer jest teraz dostępny pod adresem',
767: 'Nazwa serwera',
} satisfies i18n

File diff suppressed because it is too large Load Diff

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) {