finish setup wizard and ui language-keyboard feature

This commit is contained in:
Matt Hill
2026-01-15 23:49:24 -07:00
parent db344386ef
commit 708b273b42
28 changed files with 637 additions and 220 deletions

View File

@@ -31,6 +31,10 @@ export class AppComponent {
switch (status.status) {
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'])
break
@@ -39,6 +43,10 @@ export class AppComponent {
// Store the data drive info from status
this.stateService.dataDriveGuid = status.guid
this.stateService.attach = status.attach
// Restore keyboard from status if it was previously set
if (status.keyboard) {
this.stateService.keyboard = status.keyboard.layout
}
await this.router.navigate(['/language'])
break

View File

@@ -1,13 +1,23 @@
import { Component, inject } from '@angular/core'
import { Component, inject, signal } from '@angular/core'
import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms'
import { i18nPipe } from '@start9labs/shared'
import {
getAllKeyboardsSorted,
i18nPipe,
Keyboard,
LanguageCode,
} from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
import {
TuiButtonLoading,
TuiChevron,
TuiDataListWrapper,
TuiSelect,
} from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
import { ApiService } from '../services/api.service'
import { StateService } from '../services/state.service'
import { Keyboard, getKeyboardsForLanguage } from '../utils/languages'
@Component({
template: `
@@ -36,30 +46,22 @@ import { Keyboard, getKeyboardsForLanguage } from '../utils/languages'
</tui-textfield>
<footer>
<button tuiButton [disabled]="!selected" (click)="continue()">
<button
tuiButton
[disabled]="!selected"
[loading]="saving()"
(click)="continue()"
>
{{ 'Continue' | i18n }}
</button>
</footer>
</section>
`,
styles: `
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
}
footer {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
}
`,
imports: [
FormsModule,
TuiCardLarge,
TuiButton,
TuiButtonLoading,
TuiTextfield,
TuiChevron,
TuiSelect,
@@ -71,24 +73,38 @@ import { Keyboard, getKeyboardsForLanguage } from '../utils/languages'
})
export default class KeyboardPage {
private readonly router = inject(Router)
private readonly api = inject(ApiService)
private readonly stateService = inject(StateService)
protected readonly mobile = inject(TUI_IS_MOBILE)
readonly keyboards = getKeyboardsForLanguage(this.stateService.language)
// All keyboards, with language-specific keyboards at the top
readonly keyboards = getAllKeyboardsSorted(
this.stateService.language as LanguageCode,
)
selected =
this.keyboards.find(k => k.code === this.stateService.keyboard) ||
this.keyboards[0]
this.keyboards[0]!
readonly saving = signal(false)
readonly stringify = (kb: Keyboard) => kb.name
async back() {
await this.router.navigate(['/language'])
}
async continue() {
if (this.selected) {
this.saving.set(true)
try {
// Send keyboard to backend
await this.api.setKeyboard({
layout: this.selected.code,
model: null,
variant: null,
options: [],
})
this.stateService.keyboard = this.selected.code
await this.navigateToNextStep()
} finally {
this.saving.set(false)
}
}

View File

@@ -1,18 +1,18 @@
import { Component, inject } from '@angular/core'
import { Component, computed, inject, signal } from '@angular/core'
import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms'
import { i18nPipe, i18nService } from '@start9labs/shared'
import { i18nPipe, i18nService, Language, LANGUAGES } from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
import { StateService } from '../services/state.service'
import {
LANGUAGES,
Language,
getDefaultKeyboard,
needsKeyboardSelection,
} from '../utils/languages'
TuiButtonLoading,
TuiChevron,
TuiDataListWrapper,
TuiSelect,
} from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
import { ApiService } from '../services/api.service'
import { StateService } from '../services/state.service'
@Component({
template: `
@@ -60,25 +60,23 @@ import {
@let lang = asLanguage(item);
<div class="language-item">
<span>{{ lang.nativeName }}</span>
<small>{{ lang.tuiName | i18n }}</small>
<small>{{ lang.name | i18n }}</small>
</div>
</ng-template>
<footer>
<button tuiButton [disabled]="!selected" (click)="continue()">
<button
tuiButton
[disabled]="!selected"
[loading]="loading()"
(click)="continue()"
>
{{ 'Continue' | i18n }}
</button>
</footer>
</section>
`,
styles: `
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
}
.language-item {
display: flex;
flex-direction: column;
@@ -92,6 +90,7 @@ import {
FormsModule,
TuiCardLarge,
TuiButton,
TuiButtonLoading,
TuiTextfield,
TuiChevron,
TuiSelect,
@@ -103,6 +102,7 @@ import {
})
export default class LanguagePage {
private readonly router = inject(Router)
private readonly api = inject(ApiService)
private readonly stateService = inject(StateService)
private readonly i18nService = inject(i18nService)
@@ -112,18 +112,23 @@ export default class LanguagePage {
selected =
LANGUAGES.find(l => l.code === this.stateService.language) || LANGUAGES[0]
private readonly saving = signal(false)
// Show loading when either language is loading or saving is in progress
readonly loading = computed(() => this.i18nService.loading() || this.saving())
readonly stringify = (lang: Language) => lang.nativeName
readonly asLanguage = (item: unknown): Language => item as Language
constructor() {
if (this.selected) {
this.i18nService.setLanguage(this.selected.tuiName)
this.i18nService.setLanguage(this.selected.name)
}
}
onLanguageChange(language: Language) {
if (language) {
this.i18nService.setLanguage(language.tuiName)
this.i18nService.setLanguage(language.name)
}
}
@@ -131,31 +136,16 @@ export default class LanguagePage {
if (this.selected) {
this.stateService.language = this.selected.code
if (this.stateService.kiosk) {
if (needsKeyboardSelection(this.selected.code)) {
await this.router.navigate(['/keyboard'])
} else {
this.stateService.keyboard = getDefaultKeyboard(
this.selected.code,
).code
await this.navigateToNextStep()
}
} else {
await this.navigateToNextStep()
}
}
}
// Save language to backend
this.saving.set(true)
private async navigateToNextStep() {
if (this.stateService.dataDriveGuid) {
if (this.stateService.attach) {
this.stateService.setupType = 'attach'
await this.router.navigate(['/password'])
} else {
await this.router.navigate(['/home'])
try {
await this.api.setLanguage({ language: this.selected.name })
// Always go to keyboard selection
await this.router.navigate(['/keyboard'])
} finally {
this.saving.set(false)
}
} else {
await this.router.navigate(['/drives'])
}
}
}

View File

@@ -1,5 +1,11 @@
import * as jose from 'node-jose'
import { DiskInfo, FollowLogsRes, StartOSDiskInfo } from '@start9labs/shared'
import {
DiskInfo,
FollowLogsRes,
FullKeyboard,
SetLanguageParams,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { Observable } from 'rxjs'
import {
@@ -21,6 +27,8 @@ export abstract class ApiService {
// Status & Setup
abstract getStatus(): Promise<SetupStatusRes> // setup.status
abstract getPubKey(): Promise<void> // setup.get-pubkey
abstract setKeyboard(params: FullKeyboard): Promise<null> // setup.set-keyboard
abstract setLanguage(params: SetLanguageParams): Promise<null> // setup.set-language
// Install
abstract getDisks(): Promise<DiskInfo[]> // setup.disk.list

View File

@@ -3,10 +3,12 @@ import {
DiskInfo,
encodeBase64,
FollowLogsRes,
FullKeyboard,
HttpService,
isRpcError,
RpcError,
RPCOptions,
SetLanguageParams,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
@@ -64,6 +66,20 @@ export class LiveApiService extends ApiService {
this.pubkey = response
}
async setKeyboard(params: FullKeyboard): Promise<null> {
return this.rpcRequest({
method: 'setup.set-keyboard',
params,
})
}
async setLanguage(params: SetLanguageParams): Promise<null> {
return this.rpcRequest({
method: 'setup.set-language',
params,
})
}
async getDisks() {
return this.rpcRequest<DiskInfo[]>({
method: 'setup.disk.list',

View File

@@ -3,7 +3,9 @@ import {
DiskInfo,
encodeBase64,
FollowLogsRes,
FullKeyboard,
pauseFor,
SetLanguageParams,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
@@ -68,8 +70,13 @@ export class MockApiService extends ApiService {
this.statusIndex++
if (this.statusIndex === 1) {
// return { status: 'needs-install' }
return { status: 'incomplete', attach: false, guid: 'mock-data-guid' }
// return { status: 'needs-install', keyboard: null }
return {
status: 'incomplete',
attach: false,
guid: 'mock-data-guid',
keyboard: null,
}
}
if (this.statusIndex > 3) {
@@ -93,6 +100,16 @@ export class MockApiService extends ApiService {
})
}
async setKeyboard(_params: FullKeyboard): Promise<null> {
await pauseFor(300)
return null
}
async setLanguage(params: SetLanguageParams): Promise<null> {
await pauseFor(300)
return null
}
async getDisks(): Promise<DiskInfo[]> {
await pauseFor(500)
return MOCK_DISKS

View File

@@ -35,7 +35,7 @@ export class StateService {
// Set during install flow, or loaded from status response
language = ''
keyboard = '' // only used if kiosk
keyboard = ''
// From install response or status response (incomplete)
dataDriveGuid = ''
@@ -52,8 +52,6 @@ export class StateService {
await this.api.attach({
guid: this.dataDriveGuid,
startOsPassword: password ? await this.api.encrypt(password) : null,
language: this.language,
kiosk: this.kiosk ? { keyboard: this.keyboard } : null,
})
}
@@ -81,8 +79,6 @@ export class StateService {
await this.api.execute({
startOsLogicalname: this.dataDriveGuid,
startOsPassword: password ? await this.api.encrypt(password) : null,
language: this.language,
kiosk: this.kiosk ? { keyboard: this.keyboard } : null,
recoverySource,
})
}

View File

@@ -1,4 +1,9 @@
import { DiskInfo, PartitionInfo, StartOSDiskInfo } from '@start9labs/shared'
import {
DiskInfo,
FullKeyboard,
PartitionInfo,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
// === Echo ===
@@ -10,8 +15,13 @@ export type EchoReq = {
// === Setup Status ===
export type SetupStatusRes =
| { status: 'needs-install' }
| { status: 'incomplete'; guid: string; attach: boolean }
| { status: 'needs-install'; keyboard: FullKeyboard | null }
| {
status: 'incomplete'
guid: string
attach: boolean
keyboard: FullKeyboard | null
}
| { status: 'running'; progress: T.FullProgress; guid: string }
| { status: 'complete' }
@@ -35,8 +45,6 @@ export interface InstallOsRes {
export interface AttachParams {
startOsPassword: T.EncryptedWire | null
guid: string // data drive
language: string
kiosk: { keyboard: string } | null
}
// === Execute ===
@@ -44,8 +52,6 @@ export interface AttachParams {
export interface SetupExecuteParams {
startOsLogicalname: string
startOsPassword: T.EncryptedWire | null // null = keep existing password (for restore/transfer)
language: string
kiosk: { keyboard: string } | null
recoverySource:
| {
type: 'migrate'

View File

@@ -1,61 +0,0 @@
import { i18nKey } from '@start9labs/shared'
export interface Keyboard {
code: string
name: string
}
export interface Language {
code: string
tuiName: i18nKey
nativeName: string
}
export const LANGUAGES: Language[] = [
{ code: 'en', tuiName: 'english', nativeName: 'English' },
{ code: 'es', tuiName: 'spanish', nativeName: 'Español' },
{ code: 'de', tuiName: 'german', nativeName: 'Deutsch' },
{ code: 'fr', tuiName: 'french', nativeName: 'Français' },
{ code: 'pl', tuiName: 'polish', nativeName: 'Polski' },
]
export const KEYBOARDS_BY_LANGUAGE: Record<string, Keyboard[]> = {
en: [
{ code: 'us', name: 'US English' },
{ code: 'gb', name: 'UK English' },
],
es: [
{ code: 'es', name: 'Spanish' },
{ code: 'latam', name: 'Latin American' },
],
de: [{ code: 'de', name: 'German' }],
fr: [{ code: 'fr', name: 'French' }],
pl: [{ code: 'pl', name: 'Polish' }],
}
/**
* Get available keyboards for a language.
* Returns array of keyboards (may be 1 or more).
*/
export function getKeyboardsForLanguage(languageCode: string): Keyboard[] {
return (
KEYBOARDS_BY_LANGUAGE[languageCode] || [{ code: 'us', name: 'US English' }]
)
}
/**
* Check if keyboard selection is needed for a language.
* Returns true if there are multiple keyboard options.
*/
export function needsKeyboardSelection(languageCode: string): boolean {
const keyboards = getKeyboardsForLanguage(languageCode)
return keyboards.length > 1
}
/**
* Get the default keyboard for a language.
* Returns the first keyboard option.
*/
export function getDefaultKeyboard(languageCode: string): Keyboard {
return getKeyboardsForLanguage(languageCode)[0]!
}

View File

@@ -673,4 +673,6 @@ export default {
709: 'Laufwerk',
710: 'Übertragen',
711: 'Die Liste ist leer',
712: 'Jetzt neu starten',
713: 'Später',
} satisfies i18n

View File

@@ -673,4 +673,6 @@ export const ENGLISH = {
'Drive': 709, // as in, a storage device
'Transfer': 710, // the verb
'The list is empty': 711,
'Restart now': 712,
'Later': 713, // as in, (do it) later
} as const

View File

@@ -673,4 +673,6 @@ export default {
709: 'Unidad',
710: 'Transferir',
711: 'La lista está vacía',
712: 'Reiniciar ahora',
713: 'Más tarde',
} satisfies i18n

View File

@@ -673,4 +673,6 @@ export default {
709: 'Disque',
710: 'Transférer',
711: 'La liste est vide',
712: 'Redémarrer maintenant',
713: 'Plus tard',
} satisfies i18n

View File

@@ -673,4 +673,6 @@ export default {
709: 'Dysk',
710: 'Przenieś',
711: 'Lista jest pusta',
712: 'Uruchom ponownie teraz',
713: 'Później',
} satisfies i18n

View File

@@ -58,3 +58,5 @@ export * from './util/rpc.util'
export * from './util/to-guid'
export * from './util/to-local-iso-string'
export * from './util/unused'
export * from './util/keyboards'
export * from './util/languages'

View File

@@ -0,0 +1,90 @@
import { LanguageCode } from './languages'
/**
* Keyboard layout codes
*/
export type KeyboardCode = 'us' | 'gb' | 'es' | 'latam' | 'de' | 'fr' | 'pl'
/**
* Keyboard layout display names
*/
export type KeyboardName =
| 'US English'
| 'UK English'
| 'Spanish'
| 'Latin American'
| 'German'
| 'French'
| 'Polish'
/**
* Keyboard layout definition
*/
export interface Keyboard {
code: KeyboardCode
name: KeyboardName
}
/**
* Full keyboard configuration for backend API
*/
export interface FullKeyboard {
layout: string
model: string | null
variant: string | null
options: string[]
}
/**
* Keyboard layouts grouped by language code
*/
export const KEYBOARDS_BY_LANGUAGE: Record<LanguageCode, Keyboard[]> = {
en: [
{ code: 'us', name: 'US English' },
{ code: 'gb', name: 'UK English' },
],
es: [
{ code: 'es', name: 'Spanish' },
{ code: 'latam', name: 'Latin American' },
],
de: [{ code: 'de', name: 'German' }],
fr: [{ code: 'fr', name: 'French' }],
pl: [{ code: 'pl', name: 'Polish' }],
}
/**
* All available keyboard layouts
*/
export const ALL_KEYBOARDS: Keyboard[] = [
{ code: 'us', name: 'US English' },
{ code: 'gb', name: 'UK English' },
{ code: 'es', name: 'Spanish' },
{ code: 'latam', name: 'Latin American' },
{ code: 'de', name: 'German' },
{ code: 'fr', name: 'French' },
{ code: 'pl', name: 'Polish' },
]
/**
* Get all keyboards sorted with language-specific keyboards first,
* then remaining keyboards alphabetically by name.
*/
export function getAllKeyboardsSorted(languageCode: LanguageCode): Keyboard[] {
const languageKeyboards = KEYBOARDS_BY_LANGUAGE[languageCode]
const languageKeyboardCodes = new Set(languageKeyboards.map(kb => kb.code))
const otherKeyboards = ALL_KEYBOARDS.filter(
kb => !languageKeyboardCodes.has(kb.code),
).sort((a, b) => a.name.localeCompare(b.name))
return [...languageKeyboards, ...otherKeyboards]
}
/**
* Get the display name for a keyboard code.
*/
export function getKeyboardName(
code: KeyboardCode | string,
): KeyboardName | string {
const keyboard = ALL_KEYBOARDS.find(kb => kb.code === code)
if (keyboard) return keyboard.name
return code // fallback to the code itself if not found
}

View File

@@ -0,0 +1,44 @@
import { Languages } from '../i18n/i18n.service'
/**
* ISO language codes
*/
export type LanguageCode = 'en' | 'es' | 'de' | 'fr' | 'pl'
/**
* Language definition with metadata
*/
export interface Language {
code: LanguageCode
name: Languages
nativeName: string
}
/**
* Available languages with their metadata
*/
export const LANGUAGES: Language[] = [
{ code: 'en', name: 'english', nativeName: 'English' },
{ code: 'es', name: 'spanish', nativeName: 'Español' },
{ code: 'de', name: 'german', nativeName: 'Deutsch' },
{ code: 'fr', name: 'french', nativeName: 'Français' },
{ code: 'pl', name: 'polish', nativeName: 'Polski' },
]
/**
* Maps i18n language names to ISO language codes
*/
export const LANGUAGE_TO_CODE: Record<Languages, LanguageCode> = {
english: 'en',
spanish: 'es',
german: 'de',
french: 'fr',
polish: 'pl',
}
/**
* Params for setting language via API
*/
export interface SetLanguageParams {
language: Languages
}

View File

@@ -39,7 +39,7 @@ export class AppComponent {
.subscribe()
readonly ui = inject<PatchDB<DataModel>>(PatchDB)
.watch$('ui', 'language')
.watch$('serverInfo', 'language')
.pipe(takeUntilDestroyed())
.subscribe(language => {
this.i18n.setLanguage(language || 'english')

View File

@@ -12,6 +12,7 @@ import {
I18N_PROVIDERS,
I18N_STORAGE,
i18nService,
Languages,
RELATIVE_URL,
VERSION,
WorkspaceConfig,
@@ -128,7 +129,7 @@ export const APP_PROVIDERS = [
useFactory: () => {
const api = inject(ApiService)
return (language: string) => api.setDbValue(['language'], language)
return (language: Languages) => api.setLanguage({ language })
},
},
{

View File

@@ -12,11 +12,16 @@ import { RouterLink } from '@angular/router'
import {
DialogService,
ErrorService,
getAllKeyboardsSorted,
getKeyboardName,
i18nKey,
i18nPipe,
i18nService,
Keyboard,
KeyboardCode,
languages,
Languages,
LANGUAGE_TO_CODE,
LoadingService,
} from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
@@ -49,6 +54,7 @@ import { TitleDirective } from 'src/app/services/title.service'
import { SnekDirective } from './snek.directive'
import { UPDATE } from './update.component'
import { SystemWipeComponent } from './wipe.component'
import { KeyboardSelectComponent } from './keyboard-select.component'
@Component({
template: `
@@ -134,20 +140,38 @@ import { SystemWipeComponent } from './wipe.component'
<span tuiTitle>
<strong>
{{ 'Kiosk Mode' | i18n }}
<tui-badge size="m" appearance="primary-grayscale">
<tui-badge
size="m"
[appearance]="server.kiosk ? 'warning' : 'primary-grayscale'"
>
{{ server.kiosk ? ('Enabled' | i18n) : ('Disabled' | i18n) }}
</tui-badge>
</strong>
<span tuiSubtitle>
{{
server.kiosk === true
? ('Disable Kiosk Mode unless you need to attach a monitor'
| i18n)
: server.kiosk === false
? ('Enable Kiosk Mode if you need to attach a monitor' | i18n)
: ('Kiosk Mode is unavailable on this device' | i18n)
}}
<span tuiSubtitle [class.warning-text]="server.kiosk">
@if (server.kiosk === null) {
{{ 'Kiosk Mode is unavailable on this device' | i18n }}
} @else {
{{
server.kiosk
? ('Disable Kiosk Mode unless you need to attach a monitor'
| i18n)
: ('Enable Kiosk Mode if you need to attach a monitor' | i18n)
}}
}
</span>
@if (server.kiosk !== null && server.keyboard?.layout; as layout) {
<span tuiSubtitle class="keyboard-info">
<tui-icon icon="@tui.keyboard" />
{{ getKeyboardName(layout) }}
<button
tuiIconButton
appearance="icon"
iconStart="@tui.pencil"
size="xs"
(click)="onChangeKeyboard()"
></button>
</span>
}
</span>
@if (server.kiosk !== null) {
<button tuiButton appearance="primary" (click)="toggleKiosk()">
@@ -214,6 +238,21 @@ import { SystemWipeComponent } from './wipe.component'
[tuiAnimated].tui-leave {
animation-name: tuiFade, tuiScale;
}
.keyboard-info {
display: flex;
align-items: center;
gap: 0.25rem;
tui-icon {
font-size: 0.875rem;
}
}
.warning-text,
[tuiSubtitle].warning-text {
color: var(--tui-status-warning) !important;
}
`,
providers: [tuiCellOptionsProvider({ height: 'spacious' })],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -271,6 +310,51 @@ export default class SystemGeneralComponent {
return this.languages.find(lang => lang === this.i18nService.language)
}
// Expose shared utilities for template use
readonly getKeyboardName = getKeyboardName
/**
* Open keyboard selection dialog to change keyboard layout
*/
onChangeKeyboard() {
const server = this.server()
if (!server) return
const keyboards = getAllKeyboardsSorted(LANGUAGE_TO_CODE[server.language])
const currentKeyboard = (server.keyboard?.layout as KeyboardCode) || null
this.dialog
.openComponent<KeyboardCode | null>(
new PolymorpheusComponent(KeyboardSelectComponent, this.injector),
{
label: 'Select Keyboard Layout',
size: 's',
data: { keyboards, currentKeyboard },
},
)
.pipe(filter((code): code is KeyboardCode => code !== null))
.subscribe(keyboardCode => {
this.saveKeyboard(keyboardCode)
})
}
private async saveKeyboard(keyboardCode: KeyboardCode) {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.setKeyboard({
layout: keyboardCode,
model: null,
variant: null,
options: [],
})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
onUpdate() {
if (this.server()?.statusInfo.updated) {
this.restart()
@@ -352,19 +436,51 @@ export default class SystemGeneralComponent {
}
async toggleKiosk() {
const kiosk = this.server()?.kiosk
const server = this.server()
if (!server) return
const loader = this.loader
.open(kiosk ? 'Disabling' : 'Enabling')
.subscribe()
const kiosk = server.kiosk
// If disabling kiosk, just disable it
if (kiosk) {
await this.disableKiosk()
return
}
// Enabling kiosk - check if keyboard is already set
if (server.keyboard) {
// Keyboard already set, just enable kiosk
await this.enableKiosk()
return
}
// No keyboard set - prompt user to select from all keyboards
const keyboards = getAllKeyboardsSorted(LANGUAGE_TO_CODE[server.language])
this.promptKeyboardSelection(keyboards)
}
private promptKeyboardSelection(keyboards: Keyboard[]) {
this.dialog
.openComponent<KeyboardCode | null>(
new PolymorpheusComponent(KeyboardSelectComponent, this.injector),
{
label: 'Select Keyboard Layout',
size: 's',
data: { keyboards, currentKeyboard: null },
},
)
.pipe(filter((code): code is KeyboardCode => code !== null))
.subscribe(keyboardCode => {
this.enableKioskWithKeyboard(keyboardCode)
})
}
private async enableKiosk() {
const loader = this.loader.open('Enabling').subscribe()
try {
await this.api.toggleKiosk(!kiosk)
this.dialog
.openAlert('This change will take effect after the next boot', {
label: 'Restart to apply',
})
.subscribe()
await this.api.toggleKiosk(true)
this.promptRestart()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -372,6 +488,52 @@ export default class SystemGeneralComponent {
}
}
private async enableKioskWithKeyboard(keyboardCode: KeyboardCode) {
const loader = this.loader.open('Enabling').subscribe()
try {
await this.api.setKeyboard({
layout: keyboardCode,
model: null,
variant: null,
options: [],
})
await this.api.toggleKiosk(true)
this.promptRestart()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async disableKiosk() {
const loader = this.loader.open('Disabling').subscribe()
try {
await this.api.toggleKiosk(false)
this.promptRestart()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private promptRestart() {
this.dialog
.openConfirm({
label: 'Restart to apply',
data: {
content: 'This change will take effect after the next boot',
yes: 'Restart now',
no: 'Later',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.restart())
}
private async resetTor(wipeState: boolean) {
const loader = this.loader.open().subscribe()

View File

@@ -0,0 +1,85 @@
import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { i18nPipe, Keyboard, KeyboardCode } from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiButton, TuiDialogContext, TuiTextfield } from '@taiga-ui/core'
import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
import { injectContext } from '@taiga-ui/polymorpheus'
@Component({
template: `
<tui-textfield
tuiChevron
[stringify]="stringify"
[tuiTextfieldCleaner]="false"
>
<label tuiLabel>{{ 'Keyboard' | i18n }}</label>
@if (mobile) {
<select tuiSelect [(ngModel)]="selected" [items]="keyboards"></select>
} @else {
<input tuiSelect [(ngModel)]="selected" />
}
@if (!mobile) {
<tui-data-list-wrapper new *tuiTextfieldDropdown [items]="keyboards" />
}
</tui-textfield>
<footer>
<button tuiButton appearance="secondary" (click)="cancel()">
{{ 'Cancel' | i18n }}
</button>
<button
tuiButton
[disabled]="!selected || selected.code === initialCode"
(click)="confirm()"
>
{{ 'Confirm' | i18n }}
</button>
</footer>
`,
styles: `
p {
margin-bottom: 1rem;
}
footer {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
`,
imports: [
FormsModule,
TuiButton,
TuiTextfield,
TuiChevron,
TuiSelect,
TuiDataListWrapper,
i18nPipe,
],
})
export class KeyboardSelectComponent {
private readonly context =
injectContext<
TuiDialogContext<
KeyboardCode | null,
{ keyboards: Keyboard[]; currentKeyboard: KeyboardCode | null }
>
>()
protected readonly mobile = inject(TUI_IS_MOBILE)
readonly keyboards = this.context.data.keyboards
readonly initialCode = this.context.data.currentKeyboard
selected =
this.keyboards.find(kb => kb.code === this.initialCode) ||
this.keyboards[0]!
readonly stringify = (kb: Keyboard) => kb.name
cancel() {
this.context.completeWith(null)
}
confirm() {
this.context.completeWith(this.selected.code)
}
}

View File

@@ -1,6 +1,12 @@
import { Dump } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { StartOSDiskInfo, FetchLogsReq, FetchLogsRes } from '@start9labs/shared'
import {
FetchLogsReq,
FetchLogsRes,
FullKeyboard,
SetLanguageParams,
StartOSDiskInfo,
} from '@start9labs/shared'
import { IST, T } from '@start9labs/start-sdk'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import {
@@ -120,6 +126,12 @@ export namespace RR {
} // net.tor.reset
export type ResetTorRes = null
export type SetKeyboardReq = FullKeyboard // server.set-keyboard
export type SetKeyboardRes = null
export type SetLanguageReq = SetLanguageParams // server.set-language
export type SetLanguageRes = null
// smtp
export type SetSMTPReq = T.SmtpValue // server.set-smtp

View File

@@ -117,6 +117,10 @@ export abstract class ApiService {
abstract toggleKiosk(enable: boolean): Promise<null>
abstract setKeyboard(params: RR.SetKeyboardReq): Promise<RR.SetKeyboardRes>
abstract setLanguage(params: RR.SetLanguageReq): Promise<RR.SetLanguageRes>
abstract setDns(params: RR.SetDnsReq): Promise<RR.SetDnsRes>
abstract queryDns(params: RR.QueryDnsReq): Promise<RR.QueryDnsRes>

View File

@@ -256,6 +256,14 @@ export class LiveApiService extends ApiService {
})
}
async setKeyboard(params: RR.SetKeyboardReq): Promise<RR.SetKeyboardRes> {
return this.rpcRequest({ method: 'server.set-keyboard', params })
}
async setLanguage(params: RR.SetLanguageReq): Promise<RR.SetLanguageRes> {
return this.rpcRequest({ method: 'server.set-language', params })
}
async setDns(params: RR.SetDnsReq): Promise<RR.SetDnsRes> {
return this.rpcRequest({
method: 'net.dns.set-static',

View File

@@ -453,6 +453,41 @@ export class MockApiService extends ApiService {
return null
}
async setKeyboard(params: RR.SetKeyboardReq): Promise<RR.SetKeyboardRes> {
await pauseFor(1000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/keyboard',
value: {
layout: params.layout,
model: params.model,
variant: params.variant,
options: params.options,
},
},
]
this.mockRevision(patch)
return null
}
async setLanguage(params: RR.SetLanguageReq): Promise<RR.SetLanguageRes> {
await pauseFor(1000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/language',
value: params.language,
},
]
this.mockRevision(patch)
return null
}
async setDns(params: RR.SetDnsReq): Promise<RR.SetDnsRes> {
await pauseFor(2000)

View File

@@ -12,7 +12,6 @@ export const mockPatchData: DataModel = {
},
startosRegistry: 'https://registry.start9.com/',
snakeHighScore: 0,
language: 'english',
},
serverInfo: {
arch: 'x86_64',
@@ -220,6 +219,14 @@ export const mockPatchData: DataModel = {
ram: 8 * 1024 * 1024 * 1024,
devices: [],
kiosk: true,
language: 'english',
keyboard: {
layout: 'us',
model: null,
variant: null,
options: [],
},
// keyboard: null,
},
packageData: {
lnd: {

View File

@@ -1,8 +1,12 @@
import { Languages } from '@start9labs/shared'
import { FullKeyboard, Languages } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
export type DataModel = T.Public & {
export type DataModel = {
ui: UIData
serverInfo: T.ServerInfo & {
language: Languages
keyboard: FullKeyboard | null
}
packageData: AllPackageData
}
@@ -11,7 +15,6 @@ export type UIData = {
registries: Record<string, string | null>
snakeHighScore: number
startosRegistry: string
language: Languages
}
export type PackageDataEntry<T extends StateInfo = StateInfo> =