frontend support for setting and changing hostname

This commit is contained in:
Matt Hill
2026-02-24 10:27:22 -07:00
parent d1162272f0
commit 86ecc4cc99
26 changed files with 9381 additions and 188 deletions

View File

@@ -283,7 +283,7 @@ core/bindings/index.ts: $(call ls-files, core) $(ENVIRONMENT_FILE)
rm -rf core/bindings rm -rf core/bindings
./core/build/build-ts.sh ./core/build/build-ts.sh
ls core/bindings/*.ts | sed 's/core\/bindings\/\([^.]*\)\.ts/export { \1 } from ".\/\1";/g' | grep -v '"./index"' | tee core/bindings/index.ts ls core/bindings/*.ts | sed 's/core\/bindings\/\([^.]*\)\.ts/export { \1 } from ".\/\1";/g' | grep -v '"./index"' | tee core/bindings/index.ts
npm --prefix sdk/base exec -- prettier --config=./sdk/base/package.json -w ./core/bindings/*.ts npm --prefix sdk/base exec -- prettier --config=./sdk/base/package.json -w './core/bindings/**/*.ts'
touch core/bindings/index.ts touch core/bindings/index.ts
sdk/dist/package.json sdk/baseDist/package.json: $(call ls-files, sdk) sdk/base/lib/osBindings/index.ts sdk/dist/package.json sdk/baseDist/package.json: $(call ls-files, sdk) sdk/base/lib/osBindings/index.ts

View File

@@ -23,19 +23,14 @@ Pending tasks for AI agents. Remove items when completed.
other crate types. Extracting them requires either moving the type definitions into the sub-crate other crate types. Extracting them requires either moving the type definitions into the sub-crate
(and importing them back into `start-os`) or restructuring to share a common types crate. (and importing them back into `start-os`) or restructuring to share a common types crate.
- [ ] Use auto-generated RPC types in the frontend instead of manual duplicates - [ ] Make `SetupExecuteParams.password` optional in the backend - @dr-bonez
**Problem**: The web frontend manually defines ~755 lines of API request/response types in **Problem**: In `core/src/setup.rs`, `SetupExecuteParams` has `password: EncryptedWire` (non-nullable),
`web/projects/ui/src/app/services/api/api.types.ts` that can drift from the actual Rust types. but the frontend needs to send `null` for restore/transfer flows where the user keeps their existing
password. The `AttachParams` type correctly uses `Option<EncryptedWire>` for this purpose.
**Current state**: The Rust backend already has `#[ts(export)]` on RPC param types (e.g. **Fix**: Change `password: EncryptedWire` to `password: Option<EncryptedWire>` in `SetupExecuteParams`
`AddTunnelParams`, `SetWifiEnabledParams`, `LoginParams`), and they are generated into and handle the `None` case in the `execute` handler (similar to how `attach` handles it).
`core/bindings/`. However, commit `71b83245b` ("Chore/unexport api ts #2585", April 2024)
deliberately stopped building them into the SDK and had the frontend maintain its own types.
**Goal**: Reverse that decision — pipe the generated RPC types through the SDK into the frontend
so `api.types.ts` can import them instead of duplicating them. This eliminates drift between
backend and frontend API contracts.
- [ ] Auto-configure port forwards via UPnP/NAT-PMP/PCP - @dr-bonez - [ ] Auto-configure port forwards via UPnP/NAT-PMP/PCP - @dr-bonez

View File

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

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SetServerHostnameParams = { hostname: string }

View File

@@ -7,4 +7,5 @@ export type SetupExecuteParams = {
password: EncryptedWire password: EncryptedWire
recoverySource: RecoverySource<EncryptedWire> | null recoverySource: RecoverySource<EncryptedWire> | null
kiosk?: boolean kiosk?: boolean
hostname: string | null
} }

View File

@@ -257,6 +257,7 @@ export { SetMainStatusStatus } from './SetMainStatusStatus'
export { SetMainStatus } from './SetMainStatus' export { SetMainStatus } from './SetMainStatus'
export { SetNameParams } from './SetNameParams' export { SetNameParams } from './SetNameParams'
export { SetOutboundGatewayParams } from './SetOutboundGatewayParams' export { SetOutboundGatewayParams } from './SetOutboundGatewayParams'
export { SetServerHostnameParams } from './SetServerHostnameParams'
export { SetStaticDnsParams } from './SetStaticDnsParams' export { SetStaticDnsParams } from './SetStaticDnsParams'
export { SetupExecuteParams } from './SetupExecuteParams' export { SetupExecuteParams } from './SetupExecuteParams'
export { SetupInfo } from './SetupInfo' export { SetupInfo } from './SetupInfo'

View File

@@ -31,23 +31,15 @@ export class AppComponent {
switch (status.status) { switch (status.status) {
case 'needs-install': case 'needs-install':
// Restore keyboard from status if it was previously set
if (status.keyboard) {
this.stateService.keyboard = status.keyboard.layout
}
// Start the install flow
await this.router.navigate(['/language']) await this.router.navigate(['/language'])
break break
case 'incomplete': case 'incomplete':
// Store the data drive info from status // Store the data drive info from status
this.stateService.dataDriveGuid = status.guid if (status.guid) {
this.stateService.attach = status.attach this.stateService.dataDriveGuid = status.guid
// Restore keyboard from status if it was previously set
if (status.keyboard) {
this.stateService.keyboard = status.keyboard.layout
} }
this.stateService.attach = status.attach
await this.router.navigate(['/language']) await this.router.navigate(['/language'])
break break

View File

@@ -8,11 +8,17 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
Validators, Validators,
} from '@angular/forms' } from '@angular/forms'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared' import {
ErrorService,
generateHostname,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk' import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
import { import {
TuiButton, TuiButton,
TuiError, TuiError,
TuiHint,
TuiIcon, TuiIcon,
TuiTextfield, TuiTextfield,
TuiTitle, TuiTitle,
@@ -20,6 +26,7 @@ import {
import { import {
TuiFieldErrorPipe, TuiFieldErrorPipe,
TuiPassword, TuiPassword,
TuiTooltip,
tuiValidationErrorsProvider, tuiValidationErrorsProvider,
} from '@taiga-ui/kit' } from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
@@ -31,31 +38,49 @@ import { StateService } from '../services/state.service'
<header tuiHeader> <header tuiHeader>
<h2 tuiTitle> <h2 tuiTitle>
{{ {{
isRequired isFresh
? ('Set Master Password' | i18n) ? ('Set Up Your Server' | i18n)
: ('Set New Password (Optional)' | i18n) : ('Set New Password (Optional)' | i18n)
}} }}
<span tuiSubtitle>
{{
isRequired
? ('Make it good. Write it down.' | i18n)
: ('Skip to keep your existing password.' | i18n)
}}
</span>
</h2> </h2>
</header> </header>
<form [formGroup]="form" (ngSubmit)="submit()"> <form [formGroup]="form" (ngSubmit)="submit()">
<tui-textfield> @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
"
/>
</tui-textfield>
<tui-error
formControlName="hostname"
[error]="[] | tuiFieldError | async"
/>
}
<tui-textfield [style.margin-top.rem]="isFresh ? 1 : 0">
<label tuiLabel> <label tuiLabel>
{{ {{ isFresh ? ('Password' | i18n) : ('New Password' | i18n) }}
isRequired ? ('Enter Password' | i18n) : ('New Password' | i18n)
}}
</label> </label>
<input <input
tuiTextfield tuiTextfield
type="password" type="password"
tuiAutoFocus [tuiAutoFocus]="!isFresh"
maxlength="64" maxlength="64"
formControlName="password" formControlName="password"
/> />
@@ -87,14 +112,14 @@ import { StateService } from '../services/state.service'
<button <button
tuiButton tuiButton
[disabled]=" [disabled]="
isRequired isFresh
? form.invalid ? form.invalid
: form.controls.password.value && form.invalid : form.controls.password.value && form.invalid
" "
> >
{{ 'Finish' | i18n }} {{ 'Finish' | i18n }}
</button> </button>
@if (!isRequired) { @if (!isFresh) {
<button <button
tuiButton tuiButton
appearance="secondary" appearance="secondary"
@@ -109,6 +134,10 @@ import { StateService } from '../services/state.service'
</section> </section>
`, `,
styles: ` styles: `
.local-suffix {
color: var(--tui-text-secondary);
}
footer { footer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -131,6 +160,8 @@ import { StateService } from '../services/state.service'
TuiMapperPipe, TuiMapperPipe,
TuiHeader, TuiHeader,
TuiTitle, TuiTitle,
TuiHint,
TuiTooltip,
i18nPipe, i18nPipe,
], ],
providers: [ providers: [
@@ -139,6 +170,7 @@ import { StateService } from '../services/state.service'
minlength: 'Must be 12 characters or greater', minlength: 'Must be 12 characters or greater',
maxlength: 'Must be 64 character or less', maxlength: 'Must be 64 character or less',
match: 'Passwords do not match', match: 'Passwords do not match',
pattern: 'Only lowercase letters, numbers, and hyphens allowed',
}), }),
], ],
}) })
@@ -149,16 +181,20 @@ export default class PasswordPage {
private readonly stateService = inject(StateService) private readonly stateService = inject(StateService)
private readonly i18n = inject(i18nPipe) private readonly i18n = inject(i18nPipe)
// Password is required only for fresh install // Fresh install requires password and hostname
readonly isRequired = this.stateService.setupType === 'fresh' readonly isFresh = this.stateService.setupType === 'fresh'
readonly form = new FormGroup({ readonly form = new FormGroup({
password: new FormControl('', [ password: new FormControl('', [
...(this.isRequired ? [Validators.required] : []), ...(this.isFresh ? [Validators.required] : []),
Validators.minLength(12), Validators.minLength(12),
Validators.maxLength(64), Validators.maxLength(64),
]), ]),
confirm: new FormControl(''), confirm: new FormControl(''),
hostname: new FormControl(generateHostname(), [
Validators.required,
Validators.pattern(/^[a-z0-9][a-z0-9-]*$/),
]),
}) })
readonly validator = (value: string) => (control: AbstractControl) => readonly validator = (value: string) => (control: AbstractControl) =>
@@ -166,6 +202,10 @@ export default class PasswordPage {
? null ? null
: { match: this.i18n.transform('Passwords do not match') } : { match: this.i18n.transform('Passwords do not match') }
randomizeHostname() {
this.form.controls.hostname.setValue(generateHostname())
}
async skip() { async skip() {
// Skip means no new password - pass null // Skip means no new password - pass null
await this.executeSetup(null) await this.executeSetup(null)
@@ -177,13 +217,14 @@ export default class PasswordPage {
private async executeSetup(password: string | null) { private async executeSetup(password: string | null) {
const loader = this.loader.open('Starting setup').subscribe() const loader = this.loader.open('Starting setup').subscribe()
const hostname = this.form.controls.hostname.value || generateHostname()
try { try {
if (this.stateService.setupType === 'attach') { if (this.stateService.setupType === 'attach') {
await this.stateService.attachDrive(password) await this.stateService.attachDrive(password, hostname)
} else { } else {
// fresh, restore, or transfer - all use execute // fresh, restore, or transfer - all use execute
await this.stateService.executeSetup(password) await this.stateService.executeSetup(password, hostname)
} }
await this.router.navigate(['/loading']) await this.router.navigate(['/loading'])

View File

@@ -20,7 +20,7 @@ import { StateService } from '../services/state.service'
import { DocumentationComponent } from '../components/documentation.component' import { DocumentationComponent } from '../components/documentation.component'
import { MatrixComponent } from '../components/matrix.component' import { MatrixComponent } from '../components/matrix.component'
import { RemoveMediaDialog } from '../components/remove-media.dialog' import { RemoveMediaDialog } from '../components/remove-media.dialog'
import { SetupCompleteRes } from '../types' import { T } from '@start9labs/start-sdk'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
@Component({ @Component({
@@ -193,7 +193,7 @@ export default class SuccessPage implements AfterViewInit {
readonly stateService = inject(StateService) readonly stateService = inject(StateService)
result?: SetupCompleteRes result?: T.SetupResult
lanAddress = '' lanAddress = ''
downloaded = false downloaded = false
usbRemoved = false usbRemoved = false
@@ -232,14 +232,11 @@ export default class SuccessPage implements AfterViewInit {
removeMedia() { removeMedia() {
this.dialogs this.dialogs
.openComponent<boolean>( .openComponent<boolean>(new PolymorpheusComponent(RemoveMediaDialog), {
new PolymorpheusComponent(RemoveMediaDialog), size: 's',
{ dismissible: false,
size: 's', closeable: false,
dismissible: false, })
closeable: false,
},
)
.subscribe(() => { .subscribe(() => {
this.usbRemoved = true this.usbRemoved = true
}) })

View File

@@ -7,24 +7,16 @@ import {
} from '@start9labs/shared' } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { import { InstallOsParams, InstallOsRes } from '../types'
SetupStatusRes,
InstallOsParams,
InstallOsRes,
AttachParams,
SetupExecuteParams,
SetupCompleteRes,
EchoReq,
} from '../types'
export abstract class ApiService { export abstract class ApiService {
pubkey?: jose.JWK.Key pubkey?: jose.JWK.Key
// echo // echo
abstract echo(params: EchoReq, url: string): Promise<string> abstract echo(params: T.EchoParams, url: string): Promise<string>
// Status & Setup // Status & Setup
abstract getStatus(): Promise<SetupStatusRes> // setup.status abstract getStatus(): Promise<T.SetupStatusRes> // setup.status
abstract getPubKey(): Promise<void> // setup.get-pubkey abstract getPubKey(): Promise<void> // setup.get-pubkey
abstract setKeyboard(params: FullKeyboard): Promise<null> // setup.set-keyboard abstract setKeyboard(params: FullKeyboard): Promise<null> // setup.set-keyboard
abstract setLanguage(params: SetLanguageParams): Promise<null> // setup.set-language abstract setLanguage(params: SetLanguageParams): Promise<null> // setup.set-language
@@ -34,8 +26,8 @@ export abstract class ApiService {
abstract installOs(params: InstallOsParams): Promise<InstallOsRes> // setup.install-os abstract installOs(params: InstallOsParams): Promise<InstallOsRes> // setup.install-os
// Setup execution // Setup execution
abstract attach(params: AttachParams): Promise<T.SetupProgress> // setup.attach abstract attach(params: T.AttachParams): Promise<T.SetupProgress> // setup.attach
abstract execute(params: SetupExecuteParams): Promise<T.SetupProgress> // setup.execute abstract execute(params: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
// Recovery helpers // Recovery helpers
abstract verifyCifs( abstract verifyCifs(
@@ -43,7 +35,7 @@ export abstract class ApiService {
): Promise<Record<string, StartOSDiskInfo>> // setup.cifs.verify ): Promise<Record<string, StartOSDiskInfo>> // setup.cifs.verify
// Completion // Completion
abstract complete(): Promise<SetupCompleteRes> // setup.complete abstract complete(): Promise<T.SetupResult> // setup.complete
abstract exit(): Promise<void> // setup.exit abstract exit(): Promise<void> // setup.exit
abstract shutdown(): Promise<void> // setup.shutdown abstract shutdown(): Promise<void> // setup.shutdown

View File

@@ -15,15 +15,7 @@ import * as jose from 'node-jose'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { webSocket } from 'rxjs/webSocket' import { webSocket } from 'rxjs/webSocket'
import { ApiService } from './api.service' import { ApiService } from './api.service'
import { import { InstallOsParams, InstallOsRes } from '../types'
SetupStatusRes,
InstallOsParams,
InstallOsRes,
AttachParams,
SetupExecuteParams,
SetupCompleteRes,
EchoReq,
} from '../types'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -46,12 +38,12 @@ export class LiveApiService extends ApiService {
}) })
} }
async echo(params: EchoReq, url: string): Promise<string> { async echo(params: T.EchoParams, url: string): Promise<string> {
return this.rpcRequest({ method: 'echo', params }, url) return this.rpcRequest({ method: 'echo', params }, url)
} }
async getStatus() { async getStatus() {
return this.rpcRequest<SetupStatusRes>({ return this.rpcRequest<T.SetupStatusRes>({
method: 'setup.status', method: 'setup.status',
params: {}, params: {},
}) })
@@ -101,14 +93,14 @@ export class LiveApiService extends ApiService {
}) })
} }
async attach(params: AttachParams) { async attach(params: T.AttachParams) {
return this.rpcRequest<T.SetupProgress>({ return this.rpcRequest<T.SetupProgress>({
method: 'setup.attach', method: 'setup.attach',
params, params,
}) })
} }
async execute(params: SetupExecuteParams) { async execute(params: T.SetupExecuteParams) {
if (params.recoverySource?.type === 'backup') { if (params.recoverySource?.type === 'backup') {
const target = params.recoverySource.target const target = params.recoverySource.target
if (target.type === 'cifs') { if (target.type === 'cifs') {
@@ -130,7 +122,7 @@ export class LiveApiService extends ApiService {
} }
async complete() { async complete() {
const res = await this.rpcRequest<SetupCompleteRes>({ const res = await this.rpcRequest<T.SetupResult>({
method: 'setup.complete', method: 'setup.complete',
params: {}, params: {},
}) })

View File

@@ -11,15 +11,7 @@ import { T } from '@start9labs/start-sdk'
import * as jose from 'node-jose' import * as jose from 'node-jose'
import { interval, map, Observable } from 'rxjs' import { interval, map, Observable } from 'rxjs'
import { ApiService } from './api.service' import { ApiService } from './api.service'
import { import { InstallOsParams, InstallOsRes } from '../types'
SetupStatusRes,
InstallOsParams,
InstallOsRes,
AttachParams,
SetupExecuteParams,
SetupCompleteRes,
EchoReq,
} from '../types'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -53,7 +45,7 @@ export class MockApiService extends ApiService {
} }
} }
async echo(params: EchoReq, url: string): Promise<string> { async echo(params: T.EchoParams, url: string): Promise<string> {
if (url) { if (url) {
const num = Math.floor(Math.random() * 10) + 1 const num = Math.floor(Math.random() * 10) + 1
if (num > 8) return params.message if (num > 8) return params.message
@@ -63,23 +55,27 @@ export class MockApiService extends ApiService {
return params.message return params.message
} }
async getStatus(): Promise<SetupStatusRes> { async getStatus(): Promise<T.SetupStatusRes> {
await pauseFor(500) await pauseFor(500)
this.statusIndex++ this.statusIndex++
if (this.statusIndex === 1) { if (this.statusIndex === 1) {
return { status: 'needs-install', keyboard: null } return { status: 'needs-install' }
// return { // return {
// status: 'incomplete', // status: 'incomplete',
// attach: false, // attach: false,
// guid: 'mock-data-guid', // guid: 'mock-data-guid',
// keyboard: null,
// } // }
} }
if (this.statusIndex > 3) { if (this.statusIndex > 3) {
return { status: 'complete' } return {
status: 'complete',
hostname: 'adjective-noun',
rootCa: encodeBase64(ROOT_CA),
needsRestart: this.installCompleted,
}
} }
return { return {
@@ -147,7 +143,7 @@ export class MockApiService extends ApiService {
} }
} }
async attach(params: AttachParams): Promise<T.SetupProgress> { async attach(params: T.AttachParams): Promise<T.SetupProgress> {
await pauseFor(1000) await pauseFor(1000)
this.statusIndex = 1 // Jump to running state this.statusIndex = 1 // Jump to running state
return { return {
@@ -156,7 +152,7 @@ export class MockApiService extends ApiService {
} }
} }
async execute(params: SetupExecuteParams): Promise<T.SetupProgress> { async execute(params: T.SetupExecuteParams): Promise<T.SetupProgress> {
await pauseFor(1000) await pauseFor(1000)
this.statusIndex = 1 // Jump to running state this.statusIndex = 1 // Jump to running state
return { return {
@@ -173,7 +169,7 @@ export class MockApiService extends ApiService {
} }
} }
async complete(): Promise<SetupCompleteRes> { async complete(): Promise<T.SetupResult> {
await pauseFor(500) await pauseFor(500)
return { return {
hostname: 'adjective-noun', hostname: 'adjective-noun',

View File

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

View File

@@ -1,31 +1,6 @@
import { import { DiskInfo, PartitionInfo, StartOSDiskInfo } from '@start9labs/shared'
DiskInfo,
FullKeyboard,
PartitionInfo,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
// === Echo === // === Install OS === (no binding available)
export type EchoReq = {
message: string
}
// === Setup Status ===
export type SetupStatusRes =
| { status: 'needs-install'; keyboard: FullKeyboard | null }
| {
status: 'incomplete'
guid: string
attach: boolean
keyboard: FullKeyboard | null
}
| { status: 'running'; progress: T.FullProgress; guid: string }
| { status: 'complete' }
// === Install OS ===
export interface InstallOsParams { export interface InstallOsParams {
osDrive: string // e.g. /dev/sda osDrive: string // e.g. /dev/sda
@@ -40,48 +15,6 @@ export interface InstallOsRes {
attach: boolean attach: boolean
} }
// === Attach ===
export interface AttachParams {
password: T.EncryptedWire | null
guid: string // data drive
}
// === Execute ===
export interface SetupExecuteParams {
guid: string
password: T.EncryptedWire | null // null = keep existing password (for restore/transfer)
recoverySource:
| {
type: 'migrate'
guid: string
}
| {
type: 'backup'
target:
| { type: 'disk'; logicalname: string }
| {
type: 'cifs'
hostname: string
path: string
username: string
password: string | null
}
password: T.EncryptedWire
serverId: string
}
| null
}
// === Complete ===
export interface SetupCompleteRes {
hostname: string // unique.local
rootCa: string
needsRestart: boolean
}
// === Disk Info Helpers === // === Disk Info Helpers ===
export type StartOSDiskInfoWithId = StartOSDiskInfo & { export type StartOSDiskInfoWithId = StartOSDiskInfo & {

View File

@@ -45,6 +45,9 @@ import { i18nKey } from '../i18n/i18n.providers'
</button> </button>
} }
</tui-textfield> </tui-textfield>
@if (error) {
<p class="error">{{ error }}</p>
}
<footer class="g-buttons"> <footer class="g-buttons">
<button <button
tuiButton tuiButton
@@ -65,6 +68,10 @@ import { i18nKey } from '../i18n/i18n.providers'
color: var(--tui-status-warning); color: var(--tui-status-warning);
} }
.error {
color: var(--tui-status-negative);
}
.button { .button {
pointer-events: auto; pointer-events: auto;
margin-left: 0.25rem; margin-left: 0.25rem;
@@ -83,6 +90,7 @@ export class PromptModal {
masked = this.options.useMask masked = this.options.useMask
value = this.options.initialValue || '' value = this.options.initialValue || ''
error = ''
get options(): PromptOptions { get options(): PromptOptions {
return this.context.data return this.context.data
@@ -94,6 +102,12 @@ export class PromptModal {
submit(value: string) { submit(value: string) {
if (value || !this.options.required) { if (value || !this.options.required) {
const { pattern, patternError } = this.options
if (pattern && !new RegExp(pattern).test(value)) {
this.error = patternError || 'Invalid input'
return
}
this.error = ''
this.context.$implicit.next(value) this.context.$implicit.next(value)
} }
} }
@@ -110,4 +124,6 @@ export interface PromptOptions {
required?: boolean required?: boolean
useMask?: boolean useMask?: boolean
initialValue?: string | null initialValue?: string | null
pattern?: string
patternError?: string
} }

View File

@@ -605,10 +605,8 @@ export default {
657: 'Wählen Sie das Laufwerk mit Ihren bestehenden StartOS-Daten aus', 657: 'Wählen Sie das Laufwerk mit Ihren bestehenden StartOS-Daten aus',
658: 'Laufwerk auswählen', 658: 'Laufwerk auswählen',
659: 'Keine StartOS-Datenlaufwerke gefunden', 659: 'Keine StartOS-Datenlaufwerke gefunden',
660: 'Master-Passwort festlegen', 660: 'Richten Sie Ihren Server ein',
661: 'Neues Passwort festlegen (optional)', 661: 'Neues Passwort festlegen (optional)',
662: 'Machen Sie es gut. Schreiben Sie es auf.',
663: 'Überspringen, um Ihr bestehendes Passwort beizubehalten.',
664: 'Passwort eingeben', 664: 'Passwort eingeben',
665: 'Passwort bestätigen', 665: 'Passwort bestätigen',
666: 'Fertigstellen', 666: 'Fertigstellen',
@@ -693,4 +691,12 @@ export default {
755: 'Schnittstelle(n)', 755: 'Schnittstelle(n)',
756: 'Keine Portweiterleitungsregeln', 756: 'Keine Portweiterleitungsregeln',
757: 'Portweiterleitungsregeln am Gateway erforderlich', 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',
} satisfies i18n } satisfies i18n

View File

@@ -605,10 +605,8 @@ export const ENGLISH: Record<string, number> = {
'Select the drive containing your existing StartOS data': 657, 'Select the drive containing your existing StartOS data': 657,
'Select Drive': 658, 'Select Drive': 658,
'No StartOS data drives found': 659, 'No StartOS data drives found': 659,
'Set Master Password': 660, 'Set Up Your Server': 660,
'Set New Password (Optional)': 661, 'Set New Password (Optional)': 661,
'Make it good. Write it down.': 662,
'Skip to keep your existing password.': 663,
'Enter Password': 664, 'Enter Password': 664,
'Confirm Password': 665, 'Confirm Password': 665,
'Finish': 666, 'Finish': 666,
@@ -693,4 +691,12 @@ export const ENGLISH: Record<string, number> = {
'Interface(s)': 755, 'Interface(s)': 755,
'No port forwarding rules': 756, 'No port forwarding rules': 756,
'Port forwarding rules required on gateway': 757, '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,
} }

View File

@@ -605,10 +605,8 @@ export default {
657: 'Seleccione la unidad que contiene sus datos StartOS existentes', 657: 'Seleccione la unidad que contiene sus datos StartOS existentes',
658: 'Seleccionar unidad', 658: 'Seleccionar unidad',
659: 'No se encontraron unidades de datos StartOS', 659: 'No se encontraron unidades de datos StartOS',
660: 'Establecer contraseña maestra', 660: 'Configure su servidor',
661: 'Establecer nueva contraseña (opcional)', 661: 'Establecer nueva contraseña (opcional)',
662: 'Que sea buena. Escríbala.',
663: 'Omitir para mantener su contraseña existente.',
664: 'Introducir contraseña', 664: 'Introducir contraseña',
665: 'Confirmar contraseña', 665: 'Confirmar contraseña',
666: 'Finalizar', 666: 'Finalizar',
@@ -693,4 +691,12 @@ export default {
755: 'Interfaz/Interfaces', 755: 'Interfaz/Interfaces',
756: 'Sin reglas de redirección de puertos', 756: 'Sin reglas de redirección de puertos',
757: 'Reglas de redirección de puertos requeridas en la puerta de enlace', 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',
} satisfies i18n } satisfies i18n

View File

@@ -605,10 +605,8 @@ export default {
657: 'Sélectionnez le disque contenant vos données StartOS existantes', 657: 'Sélectionnez le disque contenant vos données StartOS existantes',
658: 'Sélectionner le disque', 658: 'Sélectionner le disque',
659: 'Aucun disque de données StartOS trouvé', 659: 'Aucun disque de données StartOS trouvé',
660: 'Définir le mot de passe maître', 660: 'Configurez votre serveur',
661: 'Définir un nouveau mot de passe (facultatif)', 661: 'Définir un nouveau mot de passe (facultatif)',
662: 'Choisissez-le bien. Notez-le.',
663: 'Ignorer pour conserver votre mot de passe existant.',
664: 'Saisir le mot de passe', 664: 'Saisir le mot de passe',
665: 'Confirmer le mot de passe', 665: 'Confirmer le mot de passe',
666: 'Terminer', 666: 'Terminer',
@@ -693,4 +691,12 @@ export default {
755: 'Interface(s)', 755: 'Interface(s)',
756: 'Aucune règle de redirection de port', 756: 'Aucune règle de redirection de port',
757: 'Règles de redirection de ports requises sur la passerelle', 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 à',
} satisfies i18n } satisfies i18n

View File

@@ -605,10 +605,8 @@ export default {
657: 'Wybierz dysk zawierający istniejące dane StartOS', 657: 'Wybierz dysk zawierający istniejące dane StartOS',
658: 'Wybierz dysk', 658: 'Wybierz dysk',
659: 'Nie znaleziono dysków danych StartOS', 659: 'Nie znaleziono dysków danych StartOS',
660: 'Ustaw hasło główne', 660: 'Skonfiguruj swój serwer',
661: 'Ustaw nowe hasło (opcjonalnie)', 661: 'Ustaw nowe hasło (opcjonalnie)',
662: 'Zadbaj o nie. Zapisz je.',
663: 'Pomiń, aby zachować istniejące hasło.',
664: 'Wprowadź hasło', 664: 'Wprowadź hasło',
665: 'Potwierdź hasło', 665: 'Potwierdź hasło',
666: 'Zakończ', 666: 'Zakończ',
@@ -693,4 +691,12 @@ export default {
755: 'Interfejs(y)', 755: 'Interfejs(y)',
756: 'Brak reguł przekierowania portów', 756: 'Brak reguł przekierowania portów',
757: 'Reguły przekierowania portów wymagane na bramce', 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',
} satisfies i18n } satisfies i18n

View File

@@ -61,3 +61,4 @@ export * from './util/to-local-iso-string'
export * from './util/unused' export * from './util/unused'
export * from './util/keyboards' export * from './util/keyboards'
export * from './util/languages' export * from './util/languages'
export * from './util/hostname'

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ import {
LANGUAGE_TO_CODE, LANGUAGE_TO_CODE,
LoadingService, LoadingService,
} from '@start9labs/shared' } from '@start9labs/shared'
import { WA_WINDOW } from '@ng-web-apis/common'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { TuiAnimated } from '@taiga-ui/cdk' import { TuiAnimated } from '@taiga-ui/cdk'
import { import {
@@ -45,7 +46,7 @@ import {
import { TuiCell, tuiCellOptionsProvider } from '@taiga-ui/layout' import { TuiCell, tuiCellOptionsProvider } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs' import { filter, Subscription } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { OSService } from 'src/app/services/os.service' import { OSService } from 'src/app/services/os.service'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -94,6 +95,18 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
} }
</button> </button>
</div> </div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.server" />
<span tuiTitle>
<strong>{{ 'Server Hostname' | i18n }}</strong>
<span tuiSubtitle>
{{ server.hostname }}
</span>
</span>
<button tuiButton (click)="onHostname()">
{{ 'Change' | i18n }}
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale"> <div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.app-window" /> <tui-icon icon="@tui.app-window" />
<span tuiTitle> <span tuiTitle>
@@ -272,6 +285,7 @@ export default class SystemGeneralComponent {
private readonly dialog = inject(DialogService) private readonly dialog = inject(DialogService)
private readonly i18n = inject(i18nPipe) private readonly i18n = inject(i18nPipe)
private readonly injector = inject(INJECTOR) private readonly injector = inject(INJECTOR)
private readonly win = inject(WA_WINDOW)
count = 0 count = 0
@@ -346,6 +360,82 @@ 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.',
},
})
.subscribe(hostname => {
if (this.win.location.hostname.endsWith('.local')) {
this.confirmHostnameChange(hostname, sub)
} else {
this.saveHostname(hostname, sub)
}
})
}
private confirmHostnameChange(hostname: string, promptSub: Subscription) {
this.dialog
.openConfirm({
label: 'Warning',
data: {
content:
'You are currently connected via your .local address. Changing the hostname will require you to switch to the new .local address.',
yes: 'Save',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.saveHostname(hostname, promptSub, true))
}
private async saveHostname(
hostname: string,
promptSub: Subscription,
wasLocal = false,
) {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.setHostname({ hostname })
if (wasLocal) {
const { protocol, port } = this.win.location
const newUrl = `${protocol}//${hostname}.local${port ? ':' + port : ''}`
this.dialog
.openConfirm({
label: 'Hostname Changed',
data: {
content:
`${this.i18n.transform('Your server is now reachable at')} ${hostname}.local` as i18nKey,
yes: 'Open new address',
no: 'Dismiss',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.win.open(newUrl, '_blank'))
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
promptSub.unsubscribe()
}
}
onTitle() { onTitle() {
const sub = this.dialog const sub = this.dialog
.openPrompt<string>({ .openPrompt<string>({

View File

@@ -121,6 +121,8 @@ export abstract class ApiService {
abstract toggleKiosk(enable: boolean): Promise<null> abstract toggleKiosk(enable: boolean): Promise<null>
abstract setHostname(params: { hostname: string }): Promise<null>
abstract setKeyboard(params: FullKeyboard): Promise<null> abstract setKeyboard(params: FullKeyboard): Promise<null>
abstract setLanguage(params: SetLanguageParams): Promise<null> abstract setLanguage(params: SetLanguageParams): Promise<null>

View File

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

View File

@@ -447,6 +447,21 @@ export class MockApiService extends ApiService {
return null return null
} }
async setHostname(params: { hostname: string }): Promise<null> {
await pauseFor(1000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/hostname',
value: params.hostname,
},
]
this.mockRevision(patch)
return null
}
async setKeyboard(params: FullKeyboard): Promise<null> { async setKeyboard(params: FullKeyboard): Promise<null> {
await pauseFor(1000) await pauseFor(1000)