Feature/consolidate setup (#3092)

* start consolidating

* add start-cli flash-os

* combine install and setup and refactor all

* use http

* undo mock

* fix translation

* translations

* use dialogservice wrapper

* better ST messaging on setup

* only warn on update if breakages (#3097)

* finish setup wizard and ui language-keyboard feature

* fix typo

* wip: localization

* remove start-tunnel readme

* switch to posix strings for language internal

* revert mock

* translate backend strings

* fix missing about text

* help text for args

* feat: add "Add new gateway" option (#3098)

* feat: add "Add new gateway" option

* Update web/projects/ui/src/app/routes/portal/components/form/controls/select.component.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* add translation

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Matt Hill <mattnine@protonmail.com>

* fix dns selection

* keyboard keymap also

* ability to shutdown after install

* revert mock

* working setup flow + manifest localization

* (mostly) redundant localization on frontend

* version bump

* omit live medium from disk list and better space management

* ignore missing package archive on 035 migration

* fix device migration

* add i18n helper to sdk

* fix install over 0.3.5.1

* fix grub config

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com>
Co-authored-by: Alex Inkin <alexander@inkin.ru>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Aiden McClelland
2026-01-27 14:44:41 -08:00
committed by GitHub
parent 99871805bd
commit c65db31fd9
251 changed files with 12163 additions and 3966 deletions

View File

@@ -4,7 +4,6 @@ StartOS web UIs are written in [Angular/Typescript](https://angular.io/docs) and
StartOS conditionally serves one of three Web UIs, depending on the state of the system and user choice.
- **install-wizard** - UI for installing StartOS, served on localhost.
- **setup-wizard** - UI for setting up StartOS, served on start.local.
- **ui** - primary UI for administering StartOS, served on various hosts unique to the instance.
@@ -69,7 +68,6 @@ You can develop using mocks (recommended to start) or against a live server. Eit
#### Start the standard development server
```sh
npm run start:install
npm run start:setup
npm run start:ui
```
@@ -122,7 +120,6 @@ Translate the English dictionary below into `<language>`. Format the result as a
#### Adding to StartOS
- In the `shared` project:
1. Create a new file (`language.ts`) in `src/i18n/dictionaries`
2. Update the `I18N_PROVIDERS` array in `src/i18n/i18n.providers.ts` (2 places)
3. Update the `languages` array in `/src/i18n/i18n.service.ts`
@@ -131,7 +128,6 @@ Translate the English dictionary below into `<language>`. Format the result as a
If you have any doubt about the above steps, check the [French example PR](https://github.com/Start9Labs/start-os/pull/2945/files) for reference.
- Here in this README:
1. Add the language to the list of supported languages below
### Updating the English dictionary

View File

@@ -121,114 +121,6 @@
}
}
},
"install-wizard": {
"projectType": "application",
"schematics": {},
"root": "projects/install-wizard",
"sourceRoot": "projects/install-wizard/src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "dist/raw/install-wizard",
"browser": ""
},
"index": "projects/install-wizard/src/index.html",
"browser": "projects/install-wizard/src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "projects/install-wizard/tsconfig.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "projects/shared/assets",
"output": "assets"
},
{
"glob": "**/*.svg",
"input": "node_modules/ionicons/dist/ionicons/svg",
"output": "./svg"
},
{
"glob": "**/*",
"input": "node_modules/@taiga-ui/icons/src",
"output": "assets/taiga-ui/icons"
}
],
"styles": [
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less"
],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "projects/install-wizard/src/environments/environment.ts",
"with": "projects/install-wizard/src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
}
]
},
"ci": {
"progress": false
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"buildTarget": "install-wizard:build",
"port": 8100
},
"configurations": {
"production": {
"buildTarget": "install-wizard:build:production"
},
"development": {
"buildTarget": "install-wizard:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n",
"options": {
"buildTarget": "install-wizard:build"
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"projects/install-wizard/src/**/*.ts",
"projects/install-wizard/src/**/*.html"
]
}
}
}
},
"setup-wizard": {
"projectType": "application",
"schematics": {},

View File

@@ -4,7 +4,6 @@ module.exports = {
'projects/ui/**/*.ts': () => 'npm run check:ui',
'projects/shared/**/*.ts': () => 'npm run check:shared',
'projects/marketplace/**/*.ts': () => 'npm run check:marketplace',
'projects/install-wizard/**/*.ts': () => 'npm run check:install',
'projects/setup-wizard/**/*.ts': () => 'npm run check:setup',
'projects/start-tunnel/**/*.ts': () => 'npm run check:tunnel',
}

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "startos-ui",
"version": "0.4.0-alpha.17",
"version": "0.4.0-alpha.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "startos-ui",
"version": "0.4.0-alpha.17",
"version": "0.4.0-alpha.18",
"license": "MIT",
"dependencies": {
"@angular/animations": "^20.3.0",

View File

@@ -1,31 +1,28 @@
{
"name": "startos-ui",
"version": "0.4.0-alpha.17",
"version": "0.4.0-alpha.18",
"author": "Start9 Labs, Inc",
"homepage": "https://start9.com/",
"license": "MIT",
"scripts": {
"ng": "ng",
"check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:install && npm run check:setup",
"check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:setup",
"check:shared": "tsc --project projects/shared/tsconfig.json --noEmit --skipLibCheck",
"check:marketplace": "tsc --project projects/marketplace/tsconfig.json --noEmit --skipLibCheck",
"check:install": "tsc --project projects/install-wizard/tsconfig.json --noEmit --skipLibCheck",
"check:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck",
"check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck",
"check:tunnel": "tsc --project projects/start-tunnel/tsconfig.json --noEmit --skipLibCheck",
"build:deps": "rimraf .angular/cache && (cd ../sdk && make bundle) && (cd ../patch-db/client && npm ci && npm run build)",
"build:install": "ng run install-wizard:build",
"build:setup": "ng run setup-wizard:build",
"build:ui": "ng run ui:build",
"build:ui:dev": "ng run ui:build:development",
"build:tunnel": "ng run start-tunnel:build",
"build:all": "npm run build:deps && npm run build:setup && npm run build:ui && npm run build:install",
"build:all": "npm run build:deps && npm run build:setup && npm run build:ui",
"build:shared": "ng build shared",
"build:marketplace": "npm run build:shared && ng build marketplace",
"analyze:ui": "ng build ui --stats-json --named-chunks && npx -y @angular-experts/hawkeye dist/raw/ui/stats.json",
"publish:shared": "npm run build:shared && npm publish ./dist/shared --access public",
"publish:marketplace": "npm run build:marketplace && npm publish ./dist/marketplace --access public",
"start:install": "npm run-script build-config && ng serve --project install-wizard --host 0.0.0.0",
"start:setup": "npm run-script build-config && ng serve --project setup-wizard --host 0.0.0.0",
"start:ui": "npm run-script build-config && ng serve --project ui --host 0.0.0.0",
"start:tunnel": "ng serve --project start-tunnel --host 0.0.0.0",

View File

@@ -1,63 +0,0 @@
<tui-root>
<main>
<img class="logo" src="assets/img/icon.png" alt="Start9" />
<section tuiCardLarge tuiSurface="floating" class="card">
<header class="header">
@if (selected) {
<button
tuiIconButton
appearance="flat-grayscale"
size="m"
class="back"
iconStart="@tui.chevron-left"
[style.border-radius.rem]="10"
(click)="selected = null"
>
Back
</button>
}
<h1>{{ selected ? 'Install Type' : 'StartOS Install' }}</h1>
@if (!selected) {
<h2>Select Disk</h2>
}
<div [style.color]="'var(--tui-text-negative)'">{{ error }}</div>
</header>
<div class="pages">
<div class="options" [class.options_selected]="selected">
@for (drive of disks$ | async; track $index) {
<button tuiCell [drive]="drive" (click)="selected = drive"></button>
}
</div>
<div class="options">
@if (guid) {
<button tuiCell (click)="install()">
<tui-icon icon="@tui.life-buoy" />
<span tuiTitle>
<strong [style.color]="'var(--tui-text-positive)'">
Re-Install StartOS
</strong>
<span tuiSubtitle>Will preserve existing StartOS data</span>
</span>
</button>
}
<button tuiCell [disabled]="!selected" (click)="warn()">
<tui-icon icon="@tui.download" />
<span tuiTitle>
@if (guid) {
<span [style.color]="'var(--tui-text-negative)'">
Factory Reset
</span>
} @else {
<span [style.color]="'var(--tui-text-positive)'">
Install StartOS
</span>
}
<span tuiSubtitle>Will delete existing data on disk</span>
</span>
</button>
</div>
</div>
</section>
</main>
</tui-root>

View File

@@ -1,71 +0,0 @@
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
::ng-deep html,
::ng-deep body,
tui-root {
height: 100%;
margin: 0;
color: var(--tui-text-primary);
}
main {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: var(--tui-background-accent-opposite-hover);
}
.logo {
width: 6rem;
margin-bottom: -2rem;
z-index: 1;
}
.card {
max-width: min(30rem, 90vw);
}
.header {
position: relative;
display: flex;
flex-direction: column;
text-align: center;
padding-top: 0.25rem;
margin-bottom: -2rem;
h2 {
margin-top: 0;
}
}
.back {
position: absolute;
top: 1rem;
}
.pages {
display: flex;
align-items: center;
overflow: hidden;
}
.options {
@include taiga.transition(margin);
min-width: 100%;
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1rem;
box-sizing: border-box;
&_selected {
margin-left: -100%;
}
}
[tuiCell]:not(:last-of-type) {
box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal);
}

View File

@@ -1,81 +0,0 @@
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { Component, inject } from '@angular/core'
import { DiskInfo, i18nKey, LoadingService, toGuid } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { filter, from } from 'rxjs'
import { SUCCESS, toWarning } from 'src/app/app.utils'
import { ApiService } from 'src/app/services/api.service'
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
standalone: false,
})
export class AppComponent {
private readonly loader = inject(LoadingService)
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
readonly disks$ = from(this.api.getDisks())
selected: DiskInfo | null = null
error = ''
get guid() {
return toGuid(this.selected)
}
async install(overwrite = false) {
const loader = this.loader.open('Installing StartOS' as i18nKey).subscribe()
const logicalname = this.selected?.logicalname || ''
try {
await this.api.install({ logicalname, overwrite })
this.reboot()
} catch (e: any) {
this.error = e.message
} finally {
loader.unsubscribe()
}
}
warn() {
this.dialogs
.open(TUI_CONFIRM, toWarning(this.selected))
.pipe(filter(Boolean))
.subscribe(() => {
this.install(true)
})
}
private async reboot() {
this.dialogs
.open('1. Remove the USB stick<br />2. Click "Reboot" below', SUCCESS)
.subscribe({
complete: async () => {
const loader = this.loader.open().subscribe()
try {
await this.api.reboot()
this.dialogs
.open(
window.location.host === 'localhost'
? 'Please wait 1-2 minutes for your server to restart'
: 'Please wait 1-2 minutes, then refresh this page to access the StartOS setup wizard.',
{
label: 'Rebooting',
size: 's',
closeable: false,
dismissible: false,
},
)
.subscribe()
} catch (e: any) {
this.error = e.message
} finally {
loader.unsubscribe()
}
},
})
}
}

View File

@@ -1,61 +0,0 @@
import {
provideHttpClient,
withFetch,
withInterceptorsFromDi,
} from '@angular/common/http'
import { NgModule } from '@angular/core'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import {
DriveComponent,
i18nPipe,
RELATIVE_URL,
WorkspaceConfig,
} from '@start9labs/shared'
import {
TuiButton,
TuiIcon,
TuiRoot,
TuiSurface,
TuiTitle,
} from '@taiga-ui/core'
import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins'
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
import { ApiService } from 'src/app/services/api.service'
import { LiveApiService } from 'src/app/services/live-api.service'
import { MockApiService } from 'src/app/services/mock-api.service'
import { AppComponent } from './app.component'
const {
useMocks,
ui: { api },
} = require('../../../../config.json') as WorkspaceConfig
@NgModule({
declarations: [AppComponent],
imports: [
BrowserAnimationsModule,
TuiRoot,
DriveComponent,
TuiButton,
TuiCardLarge,
TuiCell,
TuiIcon,
TuiSurface,
TuiTitle,
i18nPipe,
],
providers: [
NG_EVENT_PLUGINS,
{
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{
provide: RELATIVE_URL,
useValue: `/${api.url}/${api.version}`,
},
provideHttpClient(withInterceptorsFromDi(), withFetch()),
],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -1,26 +0,0 @@
import { DiskInfo } from '@start9labs/shared'
import { TuiDialogOptions } from '@taiga-ui/core'
import { TuiConfirmData } from '@taiga-ui/kit'
export const SUCCESS: Partial<TuiDialogOptions<any>> = {
label: 'Install Success!',
closeable: false,
size: 's',
data: { button: 'Reboot' },
}
export function toWarning(
disk: DiskInfo | null,
): Partial<TuiDialogOptions<TuiConfirmData>> {
return {
label: 'Warning',
size: 's',
data: {
content: `This action will COMPLETELY erase the disk ${
disk?.vendor || 'Unknown Vendor'
} - ${disk?.model || 'Unknown Model'} and install StartOS in its place`,
yes: 'Continue',
no: 'Cancel',
},
}
}

View File

@@ -1,14 +0,0 @@
import { DiskInfo } from '@start9labs/shared'
export abstract class ApiService {
abstract getDisks(): Promise<GetDisksRes> // install.disk.list
abstract install(params: InstallReq): Promise<void> // install.execute
abstract reboot(): Promise<void> // install.reboot
}
export type GetDisksRes = DiskInfo[]
export type InstallReq = {
logicalname: string
overwrite: boolean
}

View File

@@ -1,46 +0,0 @@
import { Injectable } from '@angular/core'
import {
HttpService,
isRpcError,
RpcError,
RPCOptions,
} from '@start9labs/shared'
import { ApiService, GetDisksRes, InstallReq } from './api.service'
@Injectable()
export class LiveApiService implements ApiService {
constructor(private readonly http: HttpService) {}
async getDisks(): Promise<GetDisksRes> {
return this.rpcRequest({
method: 'install.disk.list',
params: {},
})
}
async install(params: InstallReq): Promise<void> {
return this.rpcRequest<void>({
method: 'install.execute',
params,
})
}
async reboot(): Promise<void> {
return this.rpcRequest<void>({
method: 'install.reboot',
params: {},
})
}
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
const res = await this.http.rpcRequest<T>(opts)
const rpcRes = res.body
if (isRpcError(rpcRes)) {
throw new RpcError(rpcRes.error)
}
return rpcRes.result
}
}

View File

@@ -1,98 +0,0 @@
import { Injectable } from '@angular/core'
import { pauseFor } from '@start9labs/shared'
import { ApiService, GetDisksRes, InstallReq } from './api.service'
@Injectable()
export class MockApiService implements ApiService {
async getDisks(): Promise<GetDisksRes> {
await pauseFor(500)
return [
{
logicalname: 'abcd',
vendor: 'Samsung',
model: 'T5',
partitions: [
{
logicalname: 'pabcd',
label: null,
capacity: 73264762332,
used: null,
startOs: {
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
timestamp: new Date().toISOString(),
version: '0.2.17',
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: null,
},
],
capacity: 123456789123,
guid: 'uuid-uuid-uuid-uuid',
},
{
logicalname: 'dcba',
vendor: 'Crucial',
model: 'MX500',
partitions: [
{
logicalname: 'pbcba',
label: null,
capacity: 73264762332,
used: null,
startOs: {
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
timestamp: new Date().toISOString(),
version: '0.2.17',
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: null,
},
],
capacity: 124456789123,
guid: null,
},
{
logicalname: 'wxyz',
vendor: 'SanDisk',
model: 'Specialness',
partitions: [
{
logicalname: 'pbcba',
label: null,
capacity: 73264762332,
used: null,
startOs: {
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
timestamp: new Date().toISOString(),
version: '0.2.17',
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: 'guid-guid-guid-guid',
},
],
capacity: 123459789123,
guid: null,
},
]
}
async install(params: InstallReq): Promise<void> {
await pauseFor(1000)
}
async reboot(): Promise<void> {
await pauseFor(1000)
}
}

View File

@@ -1,3 +0,0 @@
export const environment = {
production: true,
}

View File

@@ -1,16 +0,0 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false,
}
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.

View File

@@ -1,30 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>StartOS Install Wizard</title>
<base href="/" />
<meta name="color-scheme" content="light dark" />
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<link
rel="icon"
type="image/png"
href="/assets/icons/favicon-96x96.png"
sizes="96x96"
/>
<link rel="icon" type="image/svg+xml" href="/assets/icons/favicon.svg" />
<link rel="shortcut icon" href="/assets/icons/favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -1,12 +0,0 @@
import { enableProdMode } from '@angular/core'
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
import { AppModule } from './app/app.module'
import { environment } from './environments/environment'
if (environment.production) {
enableProdMode()
}
platformBrowserDynamic()
.bootstrapModule(AppModule)
.catch(err => console.error(err))

View File

@@ -1,9 +0,0 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./"
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

View File

@@ -8,7 +8,7 @@
</div>
<span class="category-title" [tuiSkeleton]="categories ? false : 3">
{{
cat.key === 'ai' ? (cat.key | uppercase) : (cat.value.name | titlecase)
cat.key === 'ai' ? (cat.key | uppercase) : (cat.value.name | localize | titlecase)
}}
</span>
</button>

View File

@@ -2,12 +2,13 @@ import { TuiIcon, TuiAppearance } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { LocalizePipe } from '@start9labs/shared'
import { CategoriesComponent } from './categories.component'
import { RouterModule } from '@angular/router'
@NgModule({
imports: [RouterModule, CommonModule, TuiAppearance, TuiIcon, TuiSkeleton],
imports: [RouterModule, CommonModule, TuiAppearance, TuiIcon, TuiSkeleton, LocalizePipe],
declarations: [CategoriesComponent],
exports: [CategoriesComponent],
})

View File

@@ -1,7 +1,7 @@
import { KeyValue } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { ChangeDetectionStrategy, Component, inject, Input } from '@angular/core'
import { RouterModule } from '@angular/router'
import { ExverPipesModule, i18nPipe } from '@start9labs/shared'
import { ExverPipesModule, i18nPipe, i18nService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiAvatar, TuiLineClamp } from '@taiga-ui/kit'
import { MarketplacePkgBase } from '../../../types'
@@ -97,6 +97,8 @@ import { MarketplacePkgBase } from '../../../types'
imports: [RouterModule, TuiAvatar, ExverPipesModule, TuiLineClamp, i18nPipe],
})
export class MarketplaceDepItemComponent {
private readonly i18nService = inject(i18nService)
@Input({ required: true })
pkg!: MarketplacePkgBase
@@ -109,6 +111,7 @@ export class MarketplaceDepItemComponent {
}
getTitle(key: string): string {
return this.pkg.dependencyMetadata[key]?.title || key
const title = this.pkg.dependencyMetadata[key]?.title
return title ? this.i18nService.localize(title) : key
}
}

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { SharedPipesModule, TickerComponent } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
@Component({
selector: 'marketplace-package-hero',
@@ -125,7 +126,7 @@ export class MarketplacePackageHeroComponent {
id: string
title: string
version: string
description: { short: string }
description: { short: T.LocaleString }
icon: string
}

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { MarkdownPipe } from '@start9labs/shared'
import { LocalizePipe, MarkdownPipe } from '@start9labs/shared'
import { NgDompurifyPipe } from '@taiga-ui/dompurify'
import { MarketplacePkgBase } from '../../types'
@@ -9,7 +9,7 @@ import { MarketplacePkgBase } from '../../types'
<div class="background-border box-shadow-lg shadow-color-light">
<div class="box-container">
<h2 class="additional-detail-title">New in {{ pkg().version }}</h2>
<p [innerHTML]="pkg().releaseNotes | markdown | dompurify"></p>
<p [innerHTML]="pkg().releaseNotes | localize | markdown | dompurify"></p>
</div>
</div>
`,
@@ -21,7 +21,7 @@ import { MarketplacePkgBase } from '../../types'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgDompurifyPipe, MarkdownPipe],
imports: [NgDompurifyPipe, MarkdownPipe, LocalizePipe],
})
export class MarketplaceReleaseNotesComponent {
readonly pkg = input.required<MarketplacePkgBase>()

View File

@@ -1,7 +1,7 @@
import { Component, inject, DOCUMENT } from '@angular/core'
import { Router } from '@angular/router'
import { ErrorService } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api.service'
import { ApiService } from './services/api.service'
import { StateService } from './services/state.service'
@Component({
@@ -18,19 +18,49 @@ export class AppComponent {
async ngOnInit() {
try {
// Determine if we're in kiosk mode
this.stateService.kiosk = ['localhost', '127.0.0.1'].includes(
this.document.location.hostname,
)
const inProgress = await this.api.getStatus()
// Get pubkey for encryption
await this.api.getPubKey()
let route = 'home'
// Check setup status to determine initial route
const status = await this.api.getStatus()
if (inProgress) {
route = inProgress.status === 'complete' ? '/success' : '/loading'
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
case 'incomplete':
// 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
case 'running':
// Setup is in progress, show loading page
await this.router.navigate(['/loading'])
break
case 'complete':
// Setup execution finished, show success page
await this.router.navigate(['/success'])
break
}
await this.router.navigate([route])
} catch (e: any) {
this.errorService.handleError(e)
}

View File

@@ -9,6 +9,7 @@ import { PreloadAllModules, RouterModule } from '@angular/router'
import { WA_LOCATION } from '@ng-web-apis/common'
import initArgon from '@start9labs/argon2'
import {
I18N_PROVIDERS,
provideSetupLogsService,
RELATIVE_URL,
VERSION,
@@ -16,9 +17,9 @@ import {
} from '@start9labs/shared'
import { tuiButtonOptionsProvider, TuiRoot } from '@taiga-ui/core'
import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins'
import { ApiService } from 'src/app/services/api.service'
import { LiveApiService } from 'src/app/services/live-api.service'
import { MockApiService } from 'src/app/services/mock-api.service'
import { ApiService } from './services/api.service'
import { LiveApiService } from './services/live-api.service'
import { MockApiService } from './services/mock-api.service'
import { AppComponent } from './app.component'
import { ROUTES } from './app.routes'
@@ -41,6 +42,7 @@ const version = require('../../../../package.json').version
],
providers: [
NG_EVENT_PLUGINS,
I18N_PROVIDERS,
provideSetupLogsService(ApiService),
tuiButtonOptionsProvider({ size: 'm' }),
{

View File

@@ -1,33 +1,48 @@
import { Routes } from '@angular/router'
export const ROUTES: Routes = [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
// Entry point - app.component handles initial routing based on setup.status
{ path: '', redirectTo: '/language', pathMatch: 'full' },
// Install flow
{
path: 'language',
loadComponent: () => import('./pages/language.page'),
},
{
path: 'keyboard',
loadComponent: () => import('./pages/keyboard.page'),
},
{
path: 'drives',
loadComponent: () => import('./pages/drives.page'),
},
// Setup flow (after install or for pre-installed devices)
{
path: 'home',
loadComponent: () => import('src/app/pages/home.page'),
loadComponent: () => import('./pages/home.page'),
},
{
path: 'attach',
loadComponent: () => import('src/app/pages/attach.page'),
},
{
path: 'recover',
loadComponent: () => import('src/app/pages/recover.page'),
path: 'restore',
loadComponent: () => import('./pages/restore.page'),
},
{
path: 'transfer',
loadComponent: () => import('src/app/pages/transfer.page'),
loadComponent: () => import('./pages/transfer.page'),
},
{
path: 'storage',
loadComponent: () => import('src/app/pages/storage.page'),
path: 'password',
loadComponent: () => import('./pages/password.page'),
},
// Shared
{
path: 'loading',
loadComponent: () => import('src/app/pages/loading.page'),
loadComponent: () => import('./pages/loading.page'),
},
{
path: 'success',
loadComponent: () => import('src/app/pages/success.page'),
loadComponent: () => import('./pages/success.page'),
},
]

View File

@@ -3,39 +3,38 @@ import { Component, inject } from '@angular/core'
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { i18nKey, LoadingService, StartOSDiskInfo } from '@start9labs/shared'
import { DialogService, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDialogContext,
TuiDialogService,
TuiError,
TuiIcon,
TuiTextfield,
} from '@taiga-ui/core'
import {
TUI_VALIDATION_ERRORS,
TuiButtonLoading,
TuiFieldErrorPipe,
TuiPassword,
} from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { SERVERS, ServersResponse } from 'src/app/components/servers.component'
import { ApiService } from 'src/app/services/api.service'
import { ApiService } from '../services/api.service'
import { StartOSDiskInfoWithId } from '../types'
export interface CifsResponse {
export interface CifsResult {
cifs: T.Cifs
serverId: string
password: string
servers: StartOSDiskInfoWithId[]
}
@Component({
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<tui-textfield>
<label tuiLabel>Hostname *</label>
<label tuiLabel>{{ 'Hostname' | i18n }}*</label>
<input
tuiTextfield
formControlName="hostname"
@@ -48,17 +47,17 @@ export interface CifsResponse {
/>
<tui-textfield class="input">
<label tuiLabel>Path *</label>
<label tuiLabel>{{ 'Path' | i18n }}*</label>
<input
tuiTextfield
formControlName="path"
placeholder="/Desktop/my-folder'"
placeholder="/Desktop/my-folder"
/>
</tui-textfield>
<tui-error formControlName="path" [error]="[] | tuiFieldError | async" />
<tui-textfield class="input">
<label tuiLabel>Username *</label>
<label tuiLabel>{{ 'Username' | i18n }}*</label>
<input
tuiTextfield
formControlName="username"
@@ -71,7 +70,7 @@ export interface CifsResponse {
/>
<tui-textfield class="input">
<label tuiLabel>Password</label>
<label tuiLabel>{{ 'Password' | i18n }}</label>
<input tuiTextfield type="password" formControlName="password" />
<tui-icon tuiPassword />
</tui-textfield>
@@ -81,11 +80,14 @@ export interface CifsResponse {
tuiButton
appearance="secondary"
type="button"
[disabled]="connecting"
(click)="cancel()"
>
Cancel
{{ 'Cancel' | i18n }}
</button>
<button tuiButton [disabled]="form.invalid" [loading]="connecting">
{{ 'Connect' | i18n }}
</button>
<button tuiButton [disabled]="form.invalid">Verify</button>
</footer>
</form>
`,
@@ -97,18 +99,20 @@ export interface CifsResponse {
footer {
display: flex;
gap: 1rem;
margin-top: 1rem;
margin-top: 1.5rem;
}
`,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
TuiButton,
TuiButtonLoading,
TuiTextfield,
TuiPassword,
TuiError,
TuiFieldErrorPipe,
TuiIcon,
i18nPipe,
],
providers: [
{
@@ -120,10 +124,11 @@ export interface CifsResponse {
],
})
export class CifsComponent {
private readonly dialogs = inject(TuiDialogService)
private readonly dialogs = inject(DialogService)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly context = injectContext<TuiDialogContext<CifsResponse>>()
private readonly context = injectContext<TuiDialogContext<CifsResult>>()
connecting = false
readonly form = new FormGroup({
hostname: new FormControl('', {
@@ -149,9 +154,7 @@ export class CifsComponent {
}
async submit(): Promise<void> {
const loader = this.loader
.open('Connecting to shared folder' as i18nKey)
.subscribe()
this.connecting = true
try {
const diskInfo = await this.api.verifyCifs({
@@ -161,36 +164,25 @@ export class CifsComponent {
: null,
})
loader.unsubscribe()
const servers = Object.keys(diskInfo).map(id => ({
id,
...diskInfo[id]!,
}))
this.selectServer(diskInfo)
this.context.completeWith({
cifs: { ...this.form.getRawValue() },
servers,
})
} catch (e) {
loader.unsubscribe()
this.connecting = false
this.onFail()
}
}
private selectServer(servers: Record<string, StartOSDiskInfo>) {
this.dialogs
.open<ServersResponse>(SERVERS, {
label: 'Select Server to Restore',
data: {
servers: Object.keys(servers).map(id => ({ id, ...servers[id] })),
},
})
.subscribe(({ password, serverId }) => {
this.context.completeWith({
cifs: { ...this.form.getRawValue() },
serverId,
password,
})
})
}
private onFail() {
this.dialogs
.open(
'Unable to connect to shared folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.',
.openAlert(
'Unable to connect to network folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.',
{
label: 'Connection Failed',
size: 's',

View File

@@ -1,156 +0,0 @@
import { AsyncPipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import {
AbstractControl,
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { verify } from '@start9labs/argon2'
import { ErrorService } from '@start9labs/shared'
import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
import {
TuiButton,
TuiDialogContext,
TuiError,
TuiIcon,
TuiTextfield,
} from '@taiga-ui/core'
import {
TuiFieldErrorPipe,
TuiPassword,
tuiValidationErrorsProvider,
} from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
interface DialogData {
passwordHash?: string
storageDrive?: boolean
}
@Component({
template: `
@if (storageDrive) {
Choose a password for your server.
<em>Make it good. Write it down.</em>
} @else {
Enter the password that was used to encrypt this drive.
}
<form [formGroup]="form" [style.margin-top.rem]="1" (ngSubmit)="submit()">
<tui-textfield>
<label tuiLabel>Enter Password</label>
<input
tuiTextfield
type="password"
tuiAutoFocus
maxlength="64"
formControlName="password"
/>
<tui-icon tuiPassword />
</tui-textfield>
<tui-error
formControlName="password"
[error]="[] | tuiFieldError | async"
/>
@if (storageDrive) {
<tui-textfield [style.margin-top.rem]="1">
<label tuiLabel>Retype Password</label>
<input
tuiTextfield
type="password"
maxlength="64"
formControlName="confirm"
[tuiValidator]="form.controls.password.value | tuiMapper: validator"
/>
<tui-icon tuiPassword />
</tui-textfield>
<tui-error
formControlName="confirm"
[error]="[] | tuiFieldError | async"
/>
}
<footer>
<button
tuiButton
appearance="secondary"
type="button"
(click)="cancel()"
>
Cancel
</button>
<button tuiButton [disabled]="form.invalid">
{{ storageDrive ? 'Finish' : 'Unlock' }}
</button>
</footer>
</form>
`,
styles: `
footer {
display: flex;
gap: 1rem;
margin-top: 1rem;
justify-content: flex-end;
}
`,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiButton,
TuiError,
TuiAutoFocus,
TuiFieldErrorPipe,
TuiTextfield,
TuiPassword,
TuiValidator,
TuiIcon,
TuiMapperPipe,
],
providers: [
tuiValidationErrorsProvider({
required: 'Required',
minlength: 'Must be 12 characters or greater',
}),
],
})
export class PasswordComponent {
private readonly errorService = inject(ErrorService)
private readonly context =
injectContext<TuiDialogContext<string, DialogData>>()
readonly storageDrive = this.context.data.storageDrive
readonly form = new FormGroup({
password: new FormControl('', [
Validators.required,
Validators.minLength(12),
]),
confirm: new FormControl('', this.storageDrive ? Validators.required : []),
})
readonly validator = (value: any) => (control: AbstractControl) =>
value === control.value ? null : { match: 'Passwords do not match' }
submit() {
const password = this.form.controls.password.value || ''
if (this.storageDrive) {
this.context.completeWith(password)
return
}
try {
verify(this.context.data.passwordHash || '', password)
this.context.completeWith(password)
} catch (e) {
this.errorService.handleError('Incorrect password provided')
}
}
cancel() {
this.context.$implicit.complete()
}
}
export const PASSWORD = new PolymorpheusComponent(PasswordComponent)

View File

@@ -1,26 +0,0 @@
import { Directive, ElementRef, inject, input, Output } from '@angular/core'
import { StartOSDiskInfo } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { filter, fromEvent, switchMap } from 'rxjs'
import { PASSWORD } from 'src/app/components/password.component'
@Directive({
selector: 'button[server][password]',
})
export class PasswordDirective {
private readonly dialogs = inject(TuiDialogService)
readonly server = input.required<StartOSDiskInfo>()
@Output()
readonly password = fromEvent(inject(ElementRef).nativeElement, 'click').pipe(
switchMap(() =>
this.dialogs.open<string>(PASSWORD, {
label: 'Unlock Drive',
size: 's',
data: { passwordHash: this.server().passwordHash },
}),
),
filter(Boolean),
)
}

View File

@@ -0,0 +1,58 @@
import { Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { TuiDialogContext } from '@taiga-ui/core'
import { injectContext } from '@taiga-ui/polymorpheus'
@Component({
standalone: true,
imports: [TuiButton, i18nPipe],
template: `
<p>{{ 'This drive contains existing StartOS data.' | i18n }}</p>
<ul>
<li>
<strong class="g-positive">{{ 'Preserve' | i18n }}</strong>
{{ 'to keep your data.' | i18n }}
</li>
<li>
<strong class="g-negative">{{ 'Overwrite' | i18n }}</strong>
{{ 'to discard' | i18n }}
</li>
</ul>
<footer>
<button
tuiButton
appearance="flat-destructive"
(click)="context.completeWith(false)"
>
{{ 'Overwrite' | i18n }}
</button>
<button
tuiButton
class="preserve-btn"
(click)="context.completeWith(true)"
>
{{ 'Preserve' | i18n }}
</button>
</footer>
`,
styles: `
p {
margin: 0 0 0.75rem;
}
footer {
display: flex;
margin-top: 2rem;
gap: 0.5rem;
flex-direction: column-reverse;
}
.preserve-btn {
background: var(--tui-status-positive) !important;
}
`,
})
export class PreserveOverwriteDialog {
protected readonly context = injectContext<TuiDialogContext<boolean>>()
}

View File

@@ -1,40 +0,0 @@
import { TuiCell } from '@taiga-ui/layout'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { Component, Input } from '@angular/core'
import { RouterModule } from '@angular/router'
@Component({
selector: 'app-recover',
template: `
<a tuiCell [routerLink]="disabled ? null : '/attach'">
<tui-icon icon="@tui.box" />
<span tuiTitle>
<span class="g-positive">Use Existing Drive</span>
<span tuiSubtitle>
Attach an existing StartOS data drive (not a backup)
</span>
</span>
</a>
<a tuiCell [routerLink]="disabled ? null : '/transfer'">
<tui-icon icon="@tui.share" />
<span tuiTitle>
<span class="g-info">Transfer</span>
<span tuiSubtitle>
Transfer data from an existing StartOS data drive (not a backup) to a
new, preferred drive
</span>
</span>
</a>
<a tuiCell [routerLink]="disabled ? null : '/recover'">
<tui-icon icon="@tui.save" />
<span tuiTitle>
<span class="g-warning">Restore From Backup (Disaster Recovery)</span>
<span tuiSubtitle>Restore StartOS data from an encrypted backup</span>
</span>
</a>
`,
imports: [RouterModule, TuiIcon, TuiCell, TuiTitle],
})
export class RecoverComponent {
@Input() disabled = false
}

View File

@@ -0,0 +1,72 @@
import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { i18nPipe } from '@start9labs/shared'
import { TuiDialogContext, TuiTextfield } from '@taiga-ui/core'
import { TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { StartOSDiskInfoWithId } from '../types'
interface Data {
servers: StartOSDiskInfoWithId[]
}
@Component({
standalone: true,
imports: [FormsModule, TuiTextfield, TuiSelect, TuiDataListWrapper, i18nPipe],
template: `
<p>{{ 'Multiple backups found. Select which one to restore.' | i18n }}</p>
<tui-textfield [stringify]="stringify">
<label tuiLabel>{{ 'Backups' | i18n }}</label>
<input tuiSelect [(ngModel)]="selectedServer" />
<tui-data-list-wrapper
new
*tuiTextfieldDropdown
[items]="context.data.servers"
[itemContent]="serverContent"
/>
</tui-textfield>
<ng-template #serverContent let-server>
<div class="server-item">
<span>{{ server.id }}</span>
<!-- @TODO eos-version? -->
<small>{{ server['eos-version'] }}</small>
</div>
</ng-template>
`,
styles: `
.server-item {
display: flex;
flex-direction: column;
small {
opacity: 0.7;
}
}
`,
})
export class SelectNetworkBackupDialog {
protected readonly context =
injectContext<TuiDialogContext<StartOSDiskInfoWithId | null, Data>>()
private _selectedServer: StartOSDiskInfoWithId | null = null
get selectedServer(): StartOSDiskInfoWithId | null {
return this._selectedServer
}
set selectedServer(value: StartOSDiskInfoWithId | null) {
this._selectedServer = value
if (value) {
this.context.completeWith(value)
}
}
readonly stringify = (server: StartOSDiskInfoWithId | null) =>
server ? server.id : ''
}
export const SELECT_NETWORK_BACKUP = new PolymorpheusComponent(
SelectNetworkBackupDialog,
)

View File

@@ -1,33 +0,0 @@
import { Component } from '@angular/core'
import { ServerComponent } from '@start9labs/shared'
import { TuiDialogContext } from '@taiga-ui/core'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PasswordDirective } from 'src/app/components/password.directive'
import { StartOSDiskInfoWithId } from 'src/app/services/api.service'
interface Data {
servers: StartOSDiskInfoWithId[]
}
export interface ServersResponse {
password: string
serverId: string
}
@Component({
template: `
@for (server of context.data.servers; track $index) {
<button [server]="server" (password)="select($event, server.id)"></button>
}
`,
imports: [ServerComponent, PasswordDirective],
})
export class ServersComponent {
readonly context = injectContext<TuiDialogContext<ServersResponse, Data>>()
select(password: string, serverId: string) {
this.context.completeWith({ serverId, password })
}
}
export const SERVERS = new PolymorpheusComponent(ServersComponent)

View File

@@ -0,0 +1,65 @@
import { Component } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { i18nPipe } from '@start9labs/shared'
import {
TuiButton,
TuiDialogContext,
TuiIcon,
TuiTextfield,
} from '@taiga-ui/core'
import { TuiPassword } from '@taiga-ui/kit'
import { injectContext } from '@taiga-ui/polymorpheus'
@Component({
standalone: true,
imports: [
FormsModule,
TuiButton,
TuiTextfield,
TuiPassword,
TuiIcon,
i18nPipe,
],
template: `
<p>
{{ 'Enter the password that was used to encrypt this backup.' | i18n }}
</p>
<tui-textfield>
<label tuiLabel>{{ 'Password' | i18n }}</label>
<input
tuiTextfield
type="password"
[(ngModel)]="password"
(keyup.enter)="unlock()"
/>
<tui-icon tuiPassword />
</tui-textfield>
<footer>
<button tuiButton appearance="flat" (click)="context.completeWith(null)">
{{ 'Cancel' | i18n }}
</button>
<button tuiButton [disabled]="!password" (click)="unlock()">
{{ 'Unlock' | i18n }}
</button>
</footer>
`,
styles: `
footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.5rem;
}
`,
})
export class UnlockPasswordDialog {
protected readonly context = injectContext<TuiDialogContext<string | null>>()
password = ''
unlock() {
if (this.password) {
this.context.completeWith(this.password)
}
}
}

View File

@@ -1,102 +0,0 @@
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import {
DiskInfo,
DriveComponent,
ErrorService,
i18nKey,
LoadingService,
toGuid,
} from '@start9labs/shared'
import { TuiButton, TuiDialogService, TuiLoader } from '@taiga-ui/core'
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
import { PASSWORD } from 'src/app/components/password.component'
import { ApiService } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
@Component({
template: `
<section tuiCardLarge="compact">
<header>Use existing drive</header>
<div>Select the physical drive containing your StartOS data</div>
@if (loading) {
<tui-loader />
} @else {
@for (drive of drives; track drive) {
<button tuiCell [drive]="drive" (click)="select(drive)"></button>
} @empty {
No valid StartOS data drives found. Please make sure the drive is a
valid StartOS data drive (not a backup) and is firmly connected, then
refresh the page.
}
<footer>
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
</footer>
}
</section>
`,
imports: [TuiButton, TuiCardLarge, TuiCell, TuiLoader, DriveComponent],
})
export default class AttachPage {
private readonly apiService = inject(ApiService)
private readonly router = inject(Router)
private readonly errorService = inject(ErrorService)
private readonly stateService = inject(StateService)
private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService)
loading = true
drives: DiskInfo[] = []
async ngOnInit() {
this.stateService.setupType = 'attach'
await this.getDrives()
}
async refresh() {
this.loading = true
await this.getDrives()
}
async getDrives() {
try {
this.drives = await this.apiService
.getDrives()
.then(drives => drives.filter(toGuid))
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
select(disk: DiskInfo) {
this.dialogs
.open<string>(PASSWORD, {
label: 'Set Password',
size: 's',
data: { storageDrive: true },
})
.subscribe(password => {
this.attachDrive(toGuid(disk) || '', password)
})
}
private async attachDrive(guid: string, password: string) {
const loader = this.loader
.open('Connecting to drive' as i18nKey)
.subscribe()
try {
await this.stateService.importDrive(guid, password)
await this.router.navigate([`loading`])
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,395 @@
import { ChangeDetectorRef, Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms'
import {
DialogService,
DiskInfo,
ErrorService,
i18nKey,
i18nPipe,
LoadingService,
toGuid,
} from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import {
TuiButton,
TuiIcon,
TuiLoader,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import { TuiDataListWrapper, TuiSelect, TuiTooltip } from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { filter } from 'rxjs'
import { ApiService } from '../services/api.service'
import { StateService } from '../services/state.service'
import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog'
@Component({
template: `
<section tuiCardLarge="compact">
<header tuiHeader>
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
</header>
@if (loading) {
<tui-loader />
} @else if (drives.length === 0) {
<p class="no-drives">
{{
'No drives found. Please connect a drive and click Refresh.' | i18n
}}
</p>
} @else {
<tui-textfield [stringify]="stringify">
<label tuiLabel>{{ 'OS Drive' | i18n }}</label>
@if (mobile) {
<select
tuiSelect
[(ngModel)]="selectedOsDrive"
[items]="drives"
></select>
} @else {
<input tuiSelect [(ngModel)]="selectedOsDrive" />
}
@if (!mobile) {
<tui-data-list-wrapper
new
*tuiTextfieldDropdown
[items]="drives"
[itemContent]="driveContent"
/>
}
<tui-icon [tuiTooltip]="osDriveTooltip" />
</tui-textfield>
<tui-textfield [stringify]="stringify">
<label tuiLabel>{{ 'Data Drive' | i18n }}</label>
@if (mobile) {
<select
tuiSelect
[(ngModel)]="selectedDataDrive"
(ngModelChange)="onDataDriveChange($event)"
[items]="drives"
></select>
} @else {
<input
tuiSelect
[(ngModel)]="selectedDataDrive"
(ngModelChange)="onDataDriveChange($event)"
/>
}
@if (!mobile) {
<tui-data-list-wrapper
new
*tuiTextfieldDropdown
[items]="drives"
[itemContent]="driveContent"
/>
}
@if (preserveData === true) {
<tui-icon
icon="@tui.database"
style="color: var(--tui-status-positive); pointer-events: none"
/>
}
@if (preserveData === false) {
<tui-icon
icon="@tui.database-zap"
style="color: var(--tui-status-negative); pointer-events: none"
/>
}
<tui-icon [tuiTooltip]="dataDriveTooltip" />
</tui-textfield>
<ng-template #driveContent let-drive>
<div class="drive-item">
<span class="drive-name">
{{ drive.vendor || ('Unknown' | i18n) }}
{{ drive.model || ('Drive' | i18n) }}
</span>
<small>
{{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }}
</small>
</div>
</ng-template>
}
<footer>
@if (drives.length === 0) {
<button tuiButton appearance="secondary" (click)="refresh()">
{{ 'Refresh' | i18n }}
</button>
} @else {
<button
tuiButton
[disabled]="!selectedOsDrive || !selectedDataDrive"
(click)="continue()"
>
{{ 'Continue' | i18n }}
</button>
}
</footer>
</section>
`,
styles: `
.no-drives {
text-align: center;
color: var(--tui-text-secondary);
padding: 2rem;
}
.drive-item {
display: flex;
flex-direction: column;
small {
opacity: 0.7;
}
}
`,
imports: [
FormsModule,
TuiCardLarge,
TuiButton,
TuiIcon,
TuiLoader,
TuiTextfield,
TuiSelect,
TuiDataListWrapper,
TuiTooltip,
TuiHeader,
TuiTitle,
i18nPipe,
],
})
export default class DrivesPage {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
private readonly dialogs = inject(DialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly stateService = inject(StateService)
private readonly cdr = inject(ChangeDetectorRef)
private readonly i18n = inject(i18nPipe)
protected readonly mobile = inject(TUI_IS_MOBILE)
readonly osDriveTooltip = this.i18n.transform(
'The drive where the StartOS operating system will be installed.',
)
readonly dataDriveTooltip = this.i18n.transform(
'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.',
)
drives: DiskInfo[] = []
loading = true
selectedOsDrive: DiskInfo | null = null
selectedDataDrive: DiskInfo | null = null
preserveData: boolean | null = null
readonly stringify = (drive: DiskInfo | null) =>
drive
? `${drive.vendor || this.i18n.transform('Unknown')} ${drive.model || this.i18n.transform('Drive')}`
: ''
formatCapacity(bytes: number): string {
const gb = bytes / 1e9
if (gb >= 1000) {
return `${(gb / 1000).toFixed(1)} TB`
}
return `${gb.toFixed(0)} GB`
}
async ngOnInit() {
await this.loadDrives()
}
async refresh() {
this.loading = true
this.selectedOsDrive = null
this.selectedDataDrive = null
this.preserveData = null
await this.loadDrives()
}
onDataDriveChange(drive: DiskInfo | null) {
this.preserveData = null
if (!drive) {
return
}
const hasStartOSData = !!toGuid(drive)
if (hasStartOSData) {
this.showPreserveOverwriteDialog()
}
}
continue() {
if (!this.selectedOsDrive || !this.selectedDataDrive) return
const sameDevice =
this.selectedOsDrive.logicalname === this.selectedDataDrive.logicalname
const dataHasStartOS = !!toGuid(this.selectedDataDrive)
// Scenario 1: Same drive, has StartOS data, preserving → no warning
if (sameDevice && dataHasStartOS && this.preserveData) {
this.installOs(false)
return
}
// Scenario 2: Different drives, preserving data → warn OS only
if (!sameDevice && this.preserveData) {
this.showOsDriveWarning()
return
}
// Scenario 3: All other cases → warn about overwriting
this.showFullWarning(sameDevice)
}
private showPreserveOverwriteDialog() {
let selectionMade = false
this.dialogs
.openComponent<boolean>(
new PolymorpheusComponent(PreserveOverwriteDialog),
{
label: 'StartOS Data Detected',
size: 's',
dismissible: true,
closeable: true,
},
)
.subscribe({
next: preserve => {
selectionMade = true
this.preserveData = preserve
this.cdr.markForCheck()
},
complete: () => {
if (!selectionMade) {
// Dialog was dismissed without selection - clear the data drive
this.selectedDataDrive = null
this.preserveData = null
this.cdr.markForCheck()
}
},
})
}
private showOsDriveWarning() {
this.dialogs
.openConfirm({
label: 'Warning',
size: 's',
data: {
content: `<ul>
<li class="g-negative">${this.i18n.transform('Data on the OS drive may be overwritten.')}</li>
<li class="g-positive">${this.i18n.transform('your StartOS data on the data drive will be preserved.')}</li>
</ul>` as i18nKey,
yes: 'Continue',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
this.installOs(false)
})
}
private showFullWarning(sameDevice: boolean) {
const message = sameDevice
? `<p class="g-negative">${this.i18n.transform('Data on this drive will be overwritten.')}</p>`
: `<p class="g-negative">${this.i18n.transform('Data on both drives will be overwritten.')}</p>`
this.dialogs
.openConfirm({
label: 'Warning',
size: 's',
data: {
content: message as i18nKey,
yes: 'Continue',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
this.installOs(true)
})
}
private async installOs(wipe: boolean) {
const loader = this.loader.open('Installing StartOS').subscribe()
try {
const result = await this.api.installOs({
osDrive: this.selectedOsDrive!.logicalname,
dataDrive: {
logicalname: this.selectedDataDrive!.logicalname,
wipe,
},
})
this.stateService.dataDriveGuid = result.guid
this.stateService.attach = result.attach
loader.unsubscribe()
// Show success dialog
this.dialogs
.openConfirm({
label: 'Installation Complete!',
size: 's',
data: {
content: 'StartOS has been installed successfully.',
yes: 'Continue to Setup',
no: 'Shutdown',
},
})
.subscribe(continueSetup => {
if (continueSetup) {
this.navigateToNextStep(result.attach)
} else {
this.shutdownServer()
}
})
} catch (e: any) {
loader.unsubscribe()
this.errorService.handleError(e)
}
}
private async navigateToNextStep(attach: boolean) {
if (attach) {
this.stateService.setupType = 'attach'
await this.router.navigate(['/password'])
} else {
await this.router.navigate(['/home'])
}
}
private async shutdownServer() {
const loader = this.loader.open('Beginning shutdown').subscribe()
try {
await this.api.shutdown()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async loadDrives() {
try {
this.drives = await this.api.getDisks()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
}

View File

@@ -1,134 +1,74 @@
import { Component, inject, OnInit } from '@angular/core'
import { RouterModule } from '@angular/router'
import { ErrorService } from '@start9labs/shared'
import { TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
import { RecoverComponent } from 'src/app/components/recover.component'
import { ApiService } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { i18nPipe } from '@start9labs/shared'
import { TuiAppearance, TuiTitle } from '@taiga-ui/core'
import { TuiAvatar } from '@taiga-ui/kit'
import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout'
import { StateService } from '../services/state.service'
@Component({
template: `
<img class="logo" src="assets/img/icon.png" alt="Start9" />
@if (!loading) {
<section tuiCardLarge="compact">
<header [style.padding-top.rem]="1.25">
@if (recover) {
<button
tuiIconButton
appearance="flat-grayscale"
class="back"
iconStart="@tui.chevron-left"
(click)="recover = false"
>
Back
</button>
}
{{ recover ? 'Recover Options' : 'StartOS Setup' }}
</header>
<div class="pages">
<div class="options" [class.options_recover]="recover">
<button tuiCell [routerLink]="error || recover ? null : '/storage'">
<tui-icon icon="@tui.plus" />
<span tuiTitle>
<span class="g-positive">Start Fresh</span>
<span tuiSubtitle>
Get started with a brand new Start9 server
</span>
</span>
</button>
<button
tuiCell
[disabled]="error || recover"
(click)="recover = true"
>
<tui-icon icon="@tui.rotate-cw" />
<span tuiTitle>
<span class="g-warning">Recover</span>
<span tuiSubtitle>
Recover, restore, or transfer StartOS data
</span>
</span>
</button>
</div>
<app-recover class="options" [disabled]="!recover" />
<div tuiCardLarge="compact">
<header tuiHeader>
<h2 tuiTitle>{{ 'Select Setup Flow' | i18n }}</h2>
</header>
<button tuiCell="l" (click)="startFresh()">
<tui-avatar appearance="positive" src="@tui.plus" />
<div tuiTitle>
{{ 'Start Fresh' | i18n }}
<div tuiSubtitle>{{ 'Set up a brand new server' | i18n }}</div>
</div>
</section>
}
`,
styles: `
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
</button>
.logo {
width: 6rem;
margin: auto auto -2rem;
z-index: 1;
<button tuiCell="l" (click)="restore()">
<tui-avatar appearance="warning" src="@tui.archive-restore" />
<div tuiTitle>
{{ 'Restore from Backup' | i18n }}
<div tuiSubtitle>
{{ 'Restore StartOS data from an encrypted backup' | i18n }}
</div>
</div>
</button>
&:only-child {
margin: auto;
}
+ * {
margin-top: 0;
}
}
.back {
position: absolute;
top: 1rem;
border-radius: 10rem;
}
.pages {
display: flex;
align-items: center;
overflow: hidden;
}
.options {
@include taiga.transition(margin);
min-width: 100%;
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1rem;
box-sizing: border-box;
&_recover {
margin-left: -100%;
}
}
<button tuiCell="l" (click)="transfer()">
<tui-avatar appearance="info" src="@tui.hard-drive-download" />
<div tuiTitle>
{{ 'Transfer' | i18n }}
<div tuiSubtitle>
{{ 'Transfer data from an existing StartOS data drive' | i18n }}
</div>
</div>
</button>
</div>
`,
imports: [
RouterModule,
TuiAppearance,
TuiCardLarge,
TuiButton,
TuiHeader,
TuiCell,
TuiIcon,
TuiTitle,
RecoverComponent,
TuiAvatar,
i18nPipe,
],
})
export default class HomePage implements OnInit {
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
export default class HomePage {
private readonly router = inject(Router)
private readonly stateService = inject(StateService)
error = false
loading = true
recover = false
async ngOnInit() {
async startFresh() {
this.stateService.setupType = 'fresh'
this.stateService.recoverySource = undefined
await this.router.navigate(['/password'])
}
try {
await this.api.getPubKey()
} catch (e: any) {
this.error = true
this.errorService.handleError(e)
} finally {
this.loading = false
}
async restore() {
this.stateService.setupType = 'restore'
await this.router.navigate(['/restore'])
}
async transfer() {
this.stateService.setupType = 'transfer'
await this.router.navigate(['/transfer'])
}
}

View File

@@ -0,0 +1,124 @@
import { Component, inject, signal } from '@angular/core'
import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms'
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 {
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: `
<section tuiCardLarge="compact">
<header tuiHeader>
<h2 tuiTitle>{{ 'Select Keyboard Layout' | i18n }}</h2>
</header>
<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
[disabled]="!selected"
[loading]="saving()"
(click)="continue()"
>
{{ 'Continue' | i18n }}
</button>
</footer>
</section>
`,
imports: [
FormsModule,
TuiCardLarge,
TuiButton,
TuiButtonLoading,
TuiTextfield,
TuiChevron,
TuiSelect,
TuiDataListWrapper,
TuiHeader,
TuiTitle,
i18nPipe,
],
})
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)
// All keyboards, with language-specific keyboards at the top
readonly keyboards = getAllKeyboardsSorted(
this.stateService.language as LanguageCode,
)
selected =
this.keyboards.find(k => k.layout === this.stateService.keyboard) ||
this.keyboards[0]!
readonly saving = signal(false)
readonly stringify = (kb: Keyboard) => kb.name
async continue() {
this.saving.set(true)
try {
// Send keyboard to backend
await this.api.setKeyboard({
layout: this.selected.layout,
keymap: this.selected.keymap,
model: null,
variant: null,
options: [],
})
this.stateService.keyboard = this.selected.layout
await this.navigateToNextStep()
} finally {
this.saving.set(false)
}
}
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'])
}
} else {
await this.router.navigate(['/drives'])
}
}
}

View File

@@ -0,0 +1,151 @@
import { Component, computed, inject, signal } from '@angular/core'
import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms'
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 {
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: `
<section tuiCardLarge="compact">
<header tuiHeader>
<h2 tuiTitle>
<span class="inline-title">
<img src="assets/img/icon.png" alt="Start9" />
{{ 'Welcome to' | i18n }} StartOS
</span>
<span tuiSubtitle>{{ 'Select your language' | i18n }}</span>
</h2>
</header>
<tui-textfield
tuiChevron
[stringify]="stringify"
[tuiTextfieldCleaner]="false"
>
<label tuiLabel>{{ 'Language' | i18n }}</label>
@if (mobile) {
<select
tuiSelect
[(ngModel)]="selected"
[items]="languages"
(ngModelChange)="onLanguageChange($event)"
></select>
} @else {
<input
tuiSelect
[(ngModel)]="selected"
(ngModelChange)="onLanguageChange($event)"
/>
}
@if (!mobile) {
<tui-data-list-wrapper
*tuiTextfieldDropdown
new
[items]="languages"
[itemContent]="itemContent"
/>
}
</tui-textfield>
<ng-template #itemContent let-item>
@let lang = asLanguage(item);
<div class="language-item">
<span>{{ lang.nativeName }}</span>
<small>{{ lang.name | i18n }}</small>
</div>
</ng-template>
<footer>
<button
tuiButton
[disabled]="!selected"
[loading]="loading()"
(click)="continue()"
>
{{ 'Continue' | i18n }}
</button>
</footer>
</section>
`,
styles: `
.language-item {
display: flex;
flex-direction: column;
small {
opacity: 0.7;
}
}
`,
imports: [
FormsModule,
TuiCardLarge,
TuiButton,
TuiButtonLoading,
TuiTextfield,
TuiChevron,
TuiSelect,
TuiDataListWrapper,
TuiHeader,
TuiTitle,
i18nPipe,
],
})
export default class LanguagePage {
private readonly router = inject(Router)
private readonly api = inject(ApiService)
private readonly stateService = inject(StateService)
private readonly i18nService = inject(i18nService)
protected readonly mobile = inject(TUI_IS_MOBILE)
readonly languages = LANGUAGES
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.setLang(this.selected.name)
}
}
onLanguageChange(language: Language) {
if (language) {
this.i18nService.setLang(language.name)
}
}
async continue() {
if (this.selected) {
this.stateService.language = this.selected.code
// Save language to backend
this.saving.set(true)
try {
await this.api.setLanguage({ language: this.selected.name })
// Always go to keyboard selection
await this.router.navigate(['/keyboard'])
} finally {
this.saving.set(false)
}
}
}
}

View File

@@ -10,7 +10,7 @@ import {
DialogService,
formatProgress,
getErrorMessage,
i18nKey,
i18nPipe,
InitializingComponent,
LoadingService,
} from '@start9labs/shared'
@@ -26,17 +26,17 @@ import {
tap,
timer,
} from 'rxjs'
import { ApiService } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
import { ApiService } from '../services/api.service'
import { StateService } from '../services/state.service'
@Component({
template: `
@if (error(); as err) {
<section>
<h1>{{ 'Error initializing server' }}</h1>
<h1>{{ 'Error initializing server' | i18n }}</h1>
<p>{{ err }}</p>
<button tuiButton (click)="restart()">
{{ 'Restart server' }}
{{ 'Restart server' | i18n }}
</button>
</section>
} @else {
@@ -54,22 +54,21 @@ import { StateService } from 'src/app/services/state.service'
padding: 1rem;
margin: 1.5rem;
text-align: center;
// @TODO Theme
background: #e0e0e0;
color: #333;
--tui-background-neutral-1: rgba(0, 0, 0, 0.1);
}
`,
imports: [InitializingComponent, TuiButton],
imports: [InitializingComponent, TuiButton, i18nPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class LoadingPage {
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly dialog = inject(DialogService)
private readonly router = inject(Router)
readonly type = inject(StateService).setupType
readonly router = inject(Router)
readonly progress = toSignal(
from(this.getStatus()).pipe(
filter(Boolean),
@@ -99,12 +98,13 @@ export default class LoadingPage {
try {
const res = await this.api.getStatus()
if (!res) {
this.router.navigate(['home'])
} else if (res.status === 'complete') {
this.router.navigate(['success'])
} else {
if (res.status === 'running') {
return res
} else if (res.status === 'complete') {
this.router.navigate(['/success'])
} else {
// incomplete or needs-install - shouldn't happen on loading page
this.router.navigate(['/language'])
}
} catch (e: any) {
this.error.set(getErrorMessage(e))
@@ -119,7 +119,7 @@ export default class LoadingPage {
try {
await this.api.restart()
this.dialog
.openAlert('Wait 1-2 minutes and refresh the page' as i18nKey, {
.openAlert('Wait 1-2 minutes and refresh the page', {
label: 'Server is restarting',
})
.subscribe()

View File

@@ -0,0 +1,196 @@
import { AsyncPipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import {
AbstractControl,
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
import {
TuiButton,
TuiError,
TuiIcon,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import {
TuiFieldErrorPipe,
TuiPassword,
tuiValidationErrorsProvider,
} from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
import { StateService } from '../services/state.service'
@Component({
template: `
<section tuiCardLarge="compact">
<header tuiHeader>
<h2 tuiTitle>
{{
isRequired
? ('Set Master Password' | 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>
</header>
<form [formGroup]="form" (ngSubmit)="submit()">
<tui-textfield>
<label tuiLabel>
{{
isRequired ? ('Enter Password' | i18n) : ('New Password' | i18n)
}}
</label>
<input
tuiTextfield
type="password"
tuiAutoFocus
maxlength="64"
formControlName="password"
/>
<tui-icon tuiPassword />
</tui-textfield>
<tui-error
formControlName="password"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield [style.margin-top.rem]="1">
<label tuiLabel>{{ 'Confirm Password' | i18n }}</label>
<input
tuiTextfield
type="password"
formControlName="confirm"
[tuiValidator]="
form.controls.password.value || '' | tuiMapper: validator
"
/>
<tui-icon tuiPassword />
</tui-textfield>
<tui-error
formControlName="confirm"
[error]="[] | tuiFieldError | async"
/>
<footer>
<button
tuiButton
[disabled]="
isRequired
? form.invalid
: form.controls.password.value && form.invalid
"
>
{{ 'Finish' | i18n }}
</button>
@if (!isRequired) {
<button
tuiButton
appearance="secondary"
type="button"
(click)="skip()"
>
{{ 'Skip' | i18n }}
</button>
}
</footer>
</form>
</section>
`,
styles: `
footer {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1.5rem;
}
`,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiCardLarge,
TuiButton,
TuiError,
TuiAutoFocus,
TuiFieldErrorPipe,
TuiTextfield,
TuiPassword,
TuiValidator,
TuiIcon,
TuiMapperPipe,
TuiHeader,
TuiTitle,
i18nPipe,
],
providers: [
tuiValidationErrorsProvider({
required: 'Required',
minlength: 'Must be 12 characters or greater',
maxlength: 'Must be 64 character or less',
match: 'Passwords do not match',
}),
],
})
export default class PasswordPage {
private readonly router = inject(Router)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly stateService = inject(StateService)
private readonly i18n = inject(i18nPipe)
// Password is required only for fresh install
readonly isRequired = this.stateService.setupType === 'fresh'
readonly form = new FormGroup({
password: new FormControl('', [
...(this.isRequired ? [Validators.required] : []),
Validators.minLength(12),
Validators.maxLength(64),
]),
confirm: new FormControl(''),
})
readonly validator = (value: string) => (control: AbstractControl) =>
value === control.value
? null
: { match: this.i18n.transform('Passwords do not match') }
async skip() {
// Skip means no new password - pass null
await this.executeSetup(null)
}
async submit() {
await this.executeSetup(this.form.controls.password.value)
}
private async executeSetup(password: string | null) {
const loader = this.loader.open('Starting setup').subscribe()
try {
if (this.stateService.setupType === 'attach') {
await this.stateService.attachDrive(password)
} else {
// fresh, restore, or transfer - all use execute
await this.stateService.executeSetup(password)
}
await this.router.navigate(['/loading'])
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,144 +0,0 @@
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { ErrorService, ServerComponent } from '@start9labs/shared'
import {
TuiButton,
TuiDialogService,
TuiIcon,
TuiLoader,
TuiTitle,
} from '@taiga-ui/core'
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
import { CIFS, CifsResponse } from 'src/app/components/cifs.component'
import { PasswordDirective } from 'src/app/components/password.directive'
import { ApiService, StartOSDiskInfoFull } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
@Component({
template: `
<section tuiCardLarge="compact">
<header>Restore from Backup</header>
@if (loading) {
<tui-loader />
} @else {
<h2>Network Folder</h2>
Restore StartOS data from a folder on another computer that is connected
to the same network as your server.
<button tuiCell [style.box-shadow]="'none'" (click)="onCifs()">
<tui-icon icon="@tui.folder" />
<span tuiTitle>Open</span>
</button>
<h2>Physical Drive</h2>
<div>
Restore StartOS data from a physical drive that is plugged directly
into your server.
</div>
<strong>
Warning. Do not use this option if you are using a Raspberry Pi with
an external SSD as your main data drive. The Raspberry Pi cannot not
support more than one external drive without additional power and can
cause data corruption.
</strong>
@for (server of servers; track $index) {
<button
[server]="server"
(password)="select($event, server)"
></button>
}
<footer>
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
</footer>
}
</section>
`,
imports: [
TuiCardLarge,
TuiLoader,
TuiButton,
TuiCell,
TuiIcon,
TuiTitle,
ServerComponent,
PasswordDirective,
],
})
export default class RecoverPage {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
private readonly dialogs = inject(TuiDialogService)
private readonly errorService = inject(ErrorService)
private readonly stateService = inject(StateService)
loading = true
servers: StartOSDiskInfoFull[] = []
async ngOnInit() {
this.stateService.setupType = 'restore'
await this.getDrives()
}
async refresh() {
this.loading = true
await this.getDrives()
}
async getDrives() {
this.servers = []
try {
const drives = await this.api.getDrives()
this.servers = drives.flatMap(drive =>
drive.partitions.flatMap(partition =>
Object.entries(partition.startOs).map(([id, val]) => ({
id,
...val,
partition,
drive,
})),
),
)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
select(password: string, server: StartOSDiskInfoFull) {
this.stateService.recoverySource = {
type: 'backup',
target: {
type: 'disk',
logicalname: server.partition.logicalname,
},
serverId: server.id,
password,
}
this.router.navigate(['storage'])
}
onCifs() {
this.dialogs
.open<CifsResponse>(CIFS, {
label: 'Connect Network Folder',
})
.subscribe(({ cifs, serverId, password }) => {
this.stateService.recoverySource = {
type: 'backup',
target: {
type: 'cifs',
...cifs,
},
serverId,
password,
}
this.router.navigate(['storage'])
})
}
}

View File

@@ -0,0 +1,239 @@
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { DialogService, ErrorService, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiIcon,
TuiLoader,
TuiOptGroup,
TuiTitle,
} from '@taiga-ui/core'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { ApiService } from '../services/api.service'
import { StateService } from '../services/state.service'
import { StartOSDiskInfoFull, StartOSDiskInfoWithId } from '../types'
import { CIFS, CifsResult } from '../components/cifs.component'
import { SELECT_NETWORK_BACKUP } from '../components/select-network-backup.dialog'
import { UnlockPasswordDialog } from '../components/unlock-password.dialog'
@Component({
template: `
<section tuiCardLarge="compact">
<header tuiHeader>
<h2 tuiTitle>
{{ 'Select Backup' | i18n }}
<span tuiSubtitle>
{{ 'Select the StartOS backup you want to restore' | i18n }}
<a class="refresh" (click)="refresh()">
<tui-icon icon="@tui.rotate-cw" />
{{ 'Refresh' | i18n }}
</a>
</span>
</h2>
</header>
@if (loading) {
<tui-loader />
} @else {
<button
tuiButton
iconEnd="@tui.chevron-down"
[tuiDropdown]="dropdown"
[tuiDropdownLimitWidth]="'fixed'"
[(tuiDropdownOpen)]="open"
style="width: 100%"
>
{{ 'Select Backup' | i18n }}
</button>
<ng-template #dropdown>
<tui-data-list>
<tui-opt-group>
<button tuiOption new (click)="openCifs()">
<tui-icon icon="@tui.folder-plus" />
{{ 'Open Network Backup' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group [label]="'Physical Backups' | i18n">
@for (server of physicalServers; track server.id) {
<button tuiOption new (click)="selectPhysicalBackup(server)">
<div class="server-item">
<span>{{ server.id }}</span>
<small>
{{ server.drive.vendor }} {{ server.drive.model }} ·
{{ server.partition.logicalname }}
</small>
</div>
</button>
} @empty {
<div class="no-items">{{ 'No physical backups' | i18n }}</div>
}
</tui-opt-group>
</tui-data-list>
</ng-template>
}
</section>
`,
styles: `
.refresh {
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
color: var(--tui-text-action);
tui-icon {
font-size: 0.875rem;
}
}
.server-item {
display: flex;
flex-direction: column;
small {
opacity: 0.7;
}
}
.no-items {
padding: 0.5rem 0.75rem;
color: var(--tui-text-secondary);
font-style: italic;
}
`,
imports: [
TuiButton,
TuiCardLarge,
TuiDataList,
TuiDropdown,
TuiLoader,
TuiIcon,
TuiOptGroup,
TuiTitle,
TuiHeader,
i18nPipe,
],
})
export default class RestorePage {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
private readonly dialogs = inject(DialogService)
private readonly errorService = inject(ErrorService)
private readonly stateService = inject(StateService)
loading = true
open = false
physicalServers: StartOSDiskInfoFull[] = []
async ngOnInit() {
await this.loadDrives()
}
async refresh() {
this.loading = true
await this.loadDrives()
}
openCifs() {
this.open = false
this.dialogs
.openComponent<CifsResult>(CIFS, {
label: 'Connect Network Folder',
size: 's',
})
.subscribe(result => {
if (result) {
this.handleCifsResult(result)
}
})
}
selectPhysicalBackup(server: StartOSDiskInfoFull) {
this.open = false
this.showUnlockDialog(server.id, {
type: 'disk',
logicalname: server.partition.logicalname,
})
}
private handleCifsResult(result: CifsResult) {
if (result.servers.length === 1) {
this.showUnlockDialog(result.servers[0]!.id, {
type: 'cifs',
...result.cifs,
})
} else if (result.servers.length > 1) {
this.showSelectNetworkBackupDialog(result.cifs, result.servers)
}
}
private showSelectNetworkBackupDialog(
cifs: T.Cifs,
servers: StartOSDiskInfoWithId[],
) {
this.dialogs
.openComponent<StartOSDiskInfoWithId | null>(SELECT_NETWORK_BACKUP, {
label: 'Select Network Backup',
size: 's',
data: { servers },
})
.subscribe(server => {
if (server) {
this.showUnlockDialog(server.id, { type: 'cifs', ...cifs })
}
})
}
private showUnlockDialog(
serverId: string,
target: { type: 'disk'; logicalname: string } | ({ type: 'cifs' } & T.Cifs),
) {
this.dialogs
.openComponent<string | null>(
new PolymorpheusComponent(UnlockPasswordDialog),
{
label: 'Unlock Backup',
size: 's',
},
)
.subscribe(password => {
if (password) {
this.stateService.recoverySource = {
type: 'backup',
target,
serverId,
password,
}
this.router.navigate(['/password'])
}
})
}
private async loadDrives() {
this.physicalServers = []
try {
const drives = await this.api.getDisks()
this.physicalServers = drives.flatMap(drive =>
drive.partitions.flatMap(partition =>
Object.entries(partition.startOs).map(([id, val]) => ({
id,
...val,
partition,
drive,
})),
),
)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
}

View File

@@ -1,173 +0,0 @@
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import {
DiskInfo,
DriveComponent,
ErrorService,
i18nKey,
LoadingService,
toGuid,
} from '@start9labs/shared'
import { TuiButton, TuiDialogService, TuiLoader } from '@taiga-ui/core'
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
import { filter, of, switchMap } from 'rxjs'
import { PASSWORD } from 'src/app/components/password.component'
import { ApiService } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
@Component({
template: `
<section tuiCardLarge="compact">
@if (loading || drives.length) {
<header>Select storage drive</header>
This is the drive where your StartOS data will be stored.
} @else {
<header>No drives found</header>
Please connect a storage drive to your server. Then click "Refresh".
}
@if (loading) {
<tui-loader />
}
@for (d of drives; track d) {
<button tuiCell [drive]="d" [disabled]="isSmall(d)" (click)="select(d)">
@if (isSmall(d)) {
<span tuiSubtitle class="g-negative">Drive capacity too small</span>
}
</button>
}
<footer>
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
</footer>
</section>
`,
imports: [TuiCardLarge, TuiLoader, TuiCell, TuiButton, DriveComponent],
})
export default class StoragePage {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
private readonly dialogs = inject(TuiDialogService)
private readonly stateService = inject(StateService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
drives: DiskInfo[] = []
loading = true
async ngOnInit() {
await this.getDrives()
}
isSmall({ capacity }: DiskInfo) {
return capacity < 34359738368
}
async refresh() {
this.loading = true
await this.getDrives()
}
async getDrives() {
this.loading = true
try {
const disks = await this.api.getDrives()
if (this.stateService.setupType === 'fresh') {
this.drives = disks
} else if (
this.stateService.setupType === 'restore' &&
this.stateService.recoverySource?.type === 'backup'
) {
if (this.stateService.recoverySource.target.type === 'disk') {
const logicalname =
this.stateService.recoverySource.target.logicalname
this.drives = disks.filter(
d => !d.partitions.map(p => p.logicalname).includes(logicalname),
)
} else {
this.drives = disks
}
} else if (
this.stateService.setupType === 'transfer' &&
this.stateService.recoverySource?.type === 'migrate'
) {
const guid = this.stateService.recoverySource.guid
this.drives = disks.filter(d => {
return (
d.guid !== guid && !d.partitions.map(p => p.guid).includes(guid)
)
})
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
select(drive: DiskInfo) {
of(!toGuid(drive) && !drive.partitions.some(p => p.used))
.pipe(
switchMap(unused =>
unused
? of(true)
: this.dialogs.open(TUI_CONFIRM, {
label: 'Warning',
size: 's',
data: {
content:
'<strong>Drive contains data!</strong><p>All data stored on this drive will be permanently deleted.</p>',
yes: 'Continue',
no: 'Cancel',
},
}),
),
)
.pipe(filter(Boolean))
.subscribe(() => {
// for backup recoveries
if (this.stateService.recoverySource?.type === 'backup') {
this.setupEmbassy(
drive.logicalname,
this.stateService.recoverySource.password,
)
} else {
// for migrations and fresh setups
this.promptPassword(drive.logicalname)
}
})
}
private promptPassword(logicalname: string) {
this.dialogs
.open<string>(PASSWORD, {
label: 'Set Password',
size: 's',
data: { storageDrive: true },
})
.subscribe(password => {
this.setupEmbassy(logicalname, password)
})
}
private async setupEmbassy(
logicalname: string,
password: string,
): Promise<void> {
const loader = this.loader
.open('Connecting to drive' as i18nKey)
.subscribe()
try {
await this.stateService.setupEmbassy(logicalname, password)
await this.router.navigate(['loading'])
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -6,155 +6,224 @@ import {
ViewChild,
DOCUMENT,
} from '@angular/core'
import { DownloadHTMLService, ErrorService } from '@start9labs/shared'
import { TuiButton, TuiIcon, TuiLoader, TuiSurface } from '@taiga-ui/core'
import { TuiCardLarge } from '@taiga-ui/layout'
import { DocumentationComponent } from 'src/app/components/documentation.component'
import { MatrixComponent } from 'src/app/components/matrix.component'
import { ApiService } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
import { DownloadHTMLService, ErrorService, i18nPipe } from '@start9labs/shared'
import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
import { TuiAvatar } from '@taiga-ui/kit'
import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout'
import { ApiService } from '../services/api.service'
import { StateService } from '../services/state.service'
import { DocumentationComponent } from '../components/documentation.component'
import { MatrixComponent } from '../components/matrix.component'
import { SetupCompleteRes } from '../types'
@Component({
template: `
<canvas matrix></canvas>
<section tuiCardLarge>
<h1 class="heading">
<tui-icon icon="@tui.circle-check-big" class="g-positive" />
Setup Complete!
</h1>
@if (stateService.kiosk) {
<button tuiButton (click)="exitKiosk()">Continue to Login</button>
} @else if (lanAddress) {
@if (stateService.setupType === 'restore') {
<h3>You can now safely unplug your backup drive</h3>
} @else if (stateService.setupType === 'transfer') {
<h3>You can now safely unplug your old StartOS data drive</h3>
<header tuiHeader>
<h2 tuiTitle>
<span class="inline-title">
<tui-icon icon="@tui.circle-check-big" class="g-positive" />
{{ 'Setup Complete!' | i18n }}
</span>
@if (!stateService.kiosk) {
<span tuiSubtitle>
{{
stateService.setupType === 'restore'
? ('You can unplug your backup drive' | i18n)
: stateService.setupType === 'transfer'
? ('You can unplug your transfer drive' | i18n)
: ('http://start.local was for setup only. It will no longer work.'
| i18n)
}}
</span>
}
</h2>
</header>
@if (!result) {
<tui-loader />
} @else {
<!-- Step: Download Address Info (non-kiosk only) -->
@if (!stateService.kiosk) {
<button tuiCell="l" [disabled]="downloaded" (click)="download()">
<tui-avatar appearance="secondary" src="@tui.download" />
<div tuiTitle>
{{ 'Download Address Info' | i18n }}
<div tuiSubtitle>
{{
"Contains your server's permanent local address and Root CA"
| i18n
}}
</div>
</div>
@if (downloaded) {
<tui-icon icon="@tui.circle-check" class="g-positive" />
}
</button>
}
<h3>
http://start.local was for setup purposes only. It will no longer
work.
</h3>
<!-- Step: Remove USB Media (when restart needed) -->
@if (result.needsRestart) {
<button
tuiCell="l"
[class.disabled]="!stateService.kiosk && !downloaded"
[disabled]="(!stateService.kiosk && !downloaded) || usbRemoved"
(click)="usbRemoved = true"
>
<tui-avatar appearance="secondary" src="@tui.usb" />
<div tuiTitle>
{{ 'USB Removed' | i18n }}
<div tuiSubtitle>
{{
'Remove the USB installation media from your server' | i18n
}}
</div>
</div>
@if (usbRemoved) {
<tui-icon icon="@tui.circle-check" class="g-positive" />
}
</button>
<button tuiCardLarge tuiSurface="floating" (click)="download()">
<strong class="caps">Download address info</strong>
<span>
For future reference, this file contains your server's permanent
local address, as well as its Root Certificate Authority (Root CA).
</span>
<strong class="caps">
Download
<tui-icon icon="@tui.download" />
</strong>
</button>
<!-- Step: Restart Server -->
<button
tuiCell="l"
[class.disabled]="!usbRemoved"
[disabled]="!usbRemoved || rebooted || rebooting"
(click)="reboot()"
>
<tui-avatar appearance="secondary" src="@tui.rotate-cw" />
<div tuiTitle>
{{ 'Restart Server' | i18n }}
<div tuiSubtitle>
@if (rebooting) {
{{ 'Waiting for server to come back online' | i18n }}
} @else if (rebooted) {
{{ 'Server is back online' | i18n }}
} @else {
{{ 'Restart your server to complete setup' | i18n }}
}
</div>
</div>
@if (rebooting) {
<tui-loader />
} @else if (rebooted) {
<tui-icon icon="@tui.circle-check" class="g-positive" />
}
</button>
}
<a
tuiCardLarge
tuiSurface="floating"
target="_blank"
[attr.href]="disableLogin ? null : lanAddress"
>
<span>
In the new tab, follow instructions to trust your server's Root CA
and log in.
</span>
<strong class="caps">
Open Local Address
<tui-icon icon="@tui.external-link" />
</strong>
</a>
<app-documentation hidden [lanAddress]="lanAddress" />
} @else {
<tui-loader />
<!-- Step: Open Local Address (non-kiosk only) -->
@if (!stateService.kiosk) {
<button
tuiCell="l"
[class.disabled]="!canOpenAddress"
[disabled]="!canOpenAddress"
(click)="openLocalAddress()"
>
<tui-avatar appearance="secondary" src="@tui.external-link" />
<div tuiTitle>
{{ 'Open Local Address' | i18n }}
<div tuiSubtitle>{{ lanAddress }}</div>
</div>
</button>
<app-documentation hidden [lanAddress]="lanAddress" />
}
<!-- Step: Continue to Login (kiosk only) -->
@if (stateService.kiosk) {
<button
tuiCell="l"
[class.disabled]="result.needsRestart && !rebooted"
[disabled]="result.needsRestart && !rebooted"
(click)="exitKiosk()"
>
<tui-avatar appearance="secondary" src="@tui.log-in" />
<div tuiTitle>
{{ 'Continue to Login' | i18n }}
<div tuiSubtitle>
{{ 'Proceed to the StartOS login screen' | i18n }}
</div>
</div>
</button>
}
}
</section>
`,
styles: `
.heading {
display: flex;
gap: 1rem;
.inline-title {
display: inline-flex;
align-items: center;
margin: 0;
font: var(--tui-font-heading-4);
}
.caps {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-transform: uppercase;
}
[tuiCardLarge] {
color: var(--tui-text-primary);
text-decoration: none;
text-align: center;
&[data-appearance='floating'] {
background: var(--tui-background-neutral-1);
&:hover {
background: var(--tui-background-neutral-1-hover) !important;
}
}
}
a[tuiCardLarge]:not([href]) {
[tuiCell].disabled {
opacity: var(--tui-disabled-opacity);
pointer-events: none;
}
h3 {
text-align: left;
}
`,
imports: [
TuiCardLarge,
TuiCell,
TuiIcon,
TuiButton,
TuiSurface,
TuiLoader,
TuiAvatar,
MatrixComponent,
DocumentationComponent,
TuiLoader,
TuiHeader,
TuiTitle,
i18nPipe,
],
})
export default class SuccessPage implements AfterViewInit {
@ViewChild(DocumentationComponent, { read: ElementRef })
private readonly documentation?: ElementRef<HTMLElement>
private readonly document = inject(DOCUMENT)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly downloadHtml = inject(DownloadHTMLService)
private readonly i18n = inject(i18nPipe)
readonly stateService = inject(StateService)
lanAddress?: string
cert?: string
disableLogin = this.stateService.setupType === 'fresh'
result?: SetupCompleteRes
lanAddress = ''
downloaded = false
usbRemoved = false
rebooting = false
rebooted = false
get canOpenAddress(): boolean {
if (!this.downloaded) return false
if (this.result?.needsRestart && !this.rebooted) return false
return true
}
ngAfterViewInit() {
setTimeout(() => this.complete(), 1000)
setTimeout(() => this.complete(), 500)
}
download() {
const lanElem = this.document.getElementById('lan-addr')
if (this.downloaded) return
if (lanElem) lanElem.innerHTML = this.lanAddress || ''
const lanElem = this.document.getElementById('lan-addr')
if (lanElem) lanElem.innerHTML = this.lanAddress
this.document
.getElementById('cert')
?.setAttribute(
'href',
URL.createObjectURL(
new Blob([this.cert!], { type: 'application/octet-stream' }),
new Blob([this.result!.rootCa], { type: 'application/octet-stream' }),
),
)
const html = this.documentation?.nativeElement.innerHTML || ''
this.downloadHtml.download('StartOS-info.html', html).then(_ => {
this.disableLogin = false
this.downloadHtml.download('StartOS-info.html', html).then(() => {
this.downloaded = true
})
}
@@ -162,17 +231,58 @@ export default class SuccessPage implements AfterViewInit {
this.api.exit()
}
openLocalAddress() {
window.open(this.lanAddress, '_blank')
}
async reboot() {
this.rebooting = true
try {
await this.api.exit()
await this.pollForServer()
this.rebooted = true
this.rebooting = false
} catch (e: any) {
this.errorService.handleError(e)
this.rebooting = false
}
}
private async complete() {
try {
const ret = await this.api.complete()
if (!this.stateService.kiosk) {
this.lanAddress = ret.lanAddress.replace(/^https:/, 'http:')
this.cert = ret.rootCa
this.result = await this.api.complete()
await this.api.exit()
if (!this.stateService.kiosk) {
this.lanAddress = `http://${this.result.hostname}.local`
if (!this.result.needsRestart) {
await this.api.exit()
}
}
} catch (e: any) {
this.errorService.handleError(e)
}
}
private async pollForServer(): Promise<void> {
const maxAttempts = 60
let attempts = 0
while (attempts < maxAttempts) {
try {
await this.api.echo({ message: 'ping' }, this.lanAddress)
return
} catch {
await new Promise(resolve => setTimeout(resolve, 5000))
attempts++
}
}
throw new Error(
this.i18n.transform(
'Server did not come back online. Please check your server and try accessing it manually.',
),
)
}
}

View File

@@ -1,97 +1,172 @@
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import {
DialogService,
DiskInfo,
DriveComponent,
ErrorService,
i18nPipe,
toGuid,
} from '@start9labs/shared'
import {
TuiButton,
TuiDialogOptions,
TuiDialogService,
TuiDataList,
TuiDropdown,
TuiIcon,
TuiLoader,
TuiTitle,
} from '@taiga-ui/core'
import { TUI_CONFIRM, TuiConfirmData } from '@taiga-ui/kit'
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
import { ApiService } from '../services/api.service'
import { StateService } from '../services/state.service'
@Component({
template: `
<section tuiCardLarge="compact">
<header>Transfer</header>
Select the physical drive containing your StartOS data
<header tuiHeader>
<h2 tuiTitle>
{{ 'Transfer Data' | i18n }}
<span tuiSubtitle>
{{
'Select the drive containing your existing StartOS data' | i18n
}}
<a class="refresh" (click)="refresh()">
<tui-icon icon="@tui.rotate-cw" />
{{ 'Refresh' | i18n }}
</a>
</span>
</h2>
</header>
@if (loading) {
<tui-loader />
}
@for (drive of drives; track drive) {
<button tuiCell [drive]="drive" (click)="select(drive)"></button>
}
<footer>
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
} @else {
<button
tuiButton
iconEnd="@tui.chevron-down"
[tuiDropdown]="dropdown"
[tuiDropdownLimitWidth]="'fixed'"
[(tuiDropdownOpen)]="open"
style="width: 100%"
>
{{ 'Select Drive' | i18n }}
</button>
</footer>
<ng-template #dropdown>
<tui-data-list>
@for (drive of drives; track drive.logicalname) {
<button tuiOption new (click)="select(drive)">
<div class="drive-item">
<span>{{ drive.vendor }} {{ drive.model }}</span>
<small>{{ drive.logicalname }}</small>
</div>
</button>
} @empty {
<div class="no-items">
{{ 'No StartOS data drives found' | i18n }}
</div>
}
</tui-data-list>
</ng-template>
}
</section>
`,
imports: [TuiCardLarge, TuiCell, TuiButton, TuiLoader, DriveComponent],
styles: `
.refresh {
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
color: var(--tui-text-action);
tui-icon {
font-size: 0.875rem;
}
}
.drive-item {
display: flex;
flex-direction: column;
small {
opacity: 0.7;
}
}
.no-items {
padding: 0.5rem 0.75rem;
color: var(--tui-text-secondary);
font-style: italic;
}
`,
imports: [
TuiButton,
TuiCardLarge,
TuiDataList,
TuiDropdown,
TuiIcon,
TuiLoader,
TuiTitle,
TuiHeader,
i18nPipe,
],
})
export default class TransferPage {
private readonly apiService = inject(ApiService)
private readonly api = inject(ApiService)
private readonly router = inject(Router)
private readonly dialogs = inject(TuiDialogService)
private readonly dialogs = inject(DialogService)
private readonly errorService = inject(ErrorService)
private readonly stateService = inject(StateService)
loading = true
open = false
drives: DiskInfo[] = []
async ngOnInit() {
this.stateService.setupType = 'transfer'
await this.getDrives()
await this.loadDrives()
}
async refresh() {
await this.getDrives()
this.loading = true
await this.loadDrives()
}
async getDrives() {
this.loading = true
select(drive: DiskInfo) {
this.open = false
this.dialogs
.openConfirm({
label: 'Warning',
size: 's',
data: {
content:
'After transferring data from this drive, do not attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.',
yes: 'Continue',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
const guid = toGuid(drive)
if (guid) {
this.stateService.recoverySource = {
type: 'migrate',
guid,
}
this.router.navigate(['/password'])
}
})
}
private async loadDrives() {
try {
this.drives = await this.apiService
.getDrives()
.then(drives => drives.filter(toGuid))
const allDrives = await this.api.getDisks()
// Filter to only drives with StartOS data (guid)
this.drives = allDrives.filter(toGuid)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
select(drive: DiskInfo) {
this.dialogs
.open(TUI_CONFIRM, OPTIONS)
.pipe(filter(Boolean))
.subscribe(() => {
this.stateService.recoverySource = {
type: 'migrate',
guid: toGuid(drive) || '',
}
this.router.navigate([`storage`])
})
}
}
const OPTIONS: Partial<TuiDialogOptions<TuiConfirmData>> = {
label: 'Warning',
size: 's',
data: {
content:
'After transferring data from this drive, <b>do not</b> attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.',
yes: 'Continue',
no: 'Cancel',
},
}

View File

@@ -1,50 +1,65 @@
import * as jose from 'node-jose'
import {
DiskInfo,
DiskListResponse,
FollowLogsRes,
PartitionInfo,
FullKeyboard,
SetLanguageParams,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import { Observable } from 'rxjs'
import {
SetupStatusRes,
InstallOsParams,
InstallOsRes,
AttachParams,
SetupExecuteParams,
SetupCompleteRes,
EchoReq,
} from '../types'
export abstract class ApiService {
pubkey?: jose.JWK.Key
abstract getStatus(): Promise<T.SetupStatusRes | null> // setup.status
// echo
abstract echo(params: EchoReq, url: string): Promise<string>
// Status & Setup
abstract getStatus(): Promise<SetupStatusRes> // setup.status
abstract getPubKey(): Promise<void> // setup.get-pubkey
abstract getDrives(): Promise<DiskListResponse> // setup.disk.list
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
abstract installOs(params: InstallOsParams): Promise<InstallOsRes> // setup.install-os
// Setup execution
abstract attach(params: AttachParams): Promise<T.SetupProgress> // setup.attach
abstract execute(params: SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
// Recovery helpers
abstract verifyCifs(
cifs: T.VerifyCifsParams,
): Promise<Record<string, StartOSDiskInfo>> // setup.cifs.verify
abstract attach(importInfo: T.AttachParams): Promise<T.SetupProgress> // setup.attach
abstract execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
abstract complete(): Promise<T.SetupResult> // setup.complete
// Completion
abstract complete(): Promise<SetupCompleteRes> // setup.complete
abstract exit(): Promise<void> // setup.exit
abstract shutdown(): Promise<void> // setup.shutdown
// Logs & Progress
abstract initFollowLogs(): Promise<FollowLogsRes> // setup.logs.follow
abstract restart(): Promise<void> // setup.restart
abstract openWebsocket$<T>(guid: string): Observable<T>
// Restart (for error recovery)
abstract restart(): Promise<void> // setup.restart
async encrypt(toEncrypt: string): Promise<T.EncryptedWire> {
if (!this.pubkey) throw new Error('No pubkey found!')
const encrypted = await jose.JWE.createEncrypt(this.pubkey!)
.update(toEncrypt)
.final()
return {
encrypted,
}
return { encrypted }
}
}
export type WebsocketConfig<T> = Omit<WebSocketSubjectConfig<T>, 'url'>
export type StartOSDiskInfoWithId = StartOSDiskInfo & {
id: string
}
export type StartOSDiskInfoFull = StartOSDiskInfoWithId & {
partition: PartitionInfo
drive: DiskInfo
}

View File

@@ -1,12 +1,14 @@
import { Inject, Injectable, DOCUMENT } from '@angular/core'
import {
DiskListResponse,
DiskInfo,
encodeBase64,
FollowLogsRes,
FullKeyboard,
HttpService,
isRpcError,
RpcError,
RPCOptions,
SetLanguageParams,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
@@ -14,6 +16,15 @@ import * as jose from 'node-jose'
import { Observable } from 'rxjs'
import { webSocket } from 'rxjs/webSocket'
import { ApiService } from './api.service'
import {
SetupStatusRes,
InstallOsParams,
InstallOsRes,
AttachParams,
SetupExecuteParams,
SetupCompleteRes,
EchoReq,
} from '../types'
@Injectable({
providedIn: 'root',
@@ -36,39 +47,54 @@ export class LiveApiService extends ApiService {
})
}
async getStatus(): Promise<T.SetupStatusRes | null> {
return this.rpcRequest<T.SetupStatusRes | null>({
async echo(params: EchoReq, url: string): Promise<string> {
return this.rpcRequest({ method: 'echo', params }, url)
}
async getStatus() {
return this.rpcRequest<SetupStatusRes>({
method: 'setup.status',
params: {},
})
}
/**
* We want to update the pubkey, which means that we will call in clearnet the
* getPubKey, and all the information is never in the clear, and only public
* information is sent across the network. We don't want to expose that we do
* this wil all public/private key, which means that there is no information loss
* through the network.
*/
async getPubKey(): Promise<void> {
async getPubKey() {
const response: jose.JWK.Key = await this.rpcRequest({
method: 'setup.get-pubkey',
params: {},
})
this.pubkey = response
}
async getDrives(): Promise<DiskListResponse> {
return this.rpcRequest<DiskListResponse>({
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',
params: {},
})
}
async verifyCifs(
source: T.VerifyCifsParams,
): Promise<Record<string, StartOSDiskInfo>> {
async installOs(params: InstallOsParams) {
return this.rpcRequest<InstallOsRes>({
method: 'setup.install-os',
params,
})
}
async verifyCifs(source: T.VerifyCifsParams) {
source.path = source.path.replace('/\\/g', '/')
return this.rpcRequest<Record<string, StartOSDiskInfo>>({
method: 'setup.cifs.verify',
@@ -76,33 +102,36 @@ export class LiveApiService extends ApiService {
})
}
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
async attach(params: AttachParams) {
return this.rpcRequest<T.SetupProgress>({
method: 'setup.attach',
params,
})
}
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
if (setupInfo.recoverySource?.type === 'backup') {
if (isCifsSource(setupInfo.recoverySource.target)) {
setupInfo.recoverySource.target.path =
setupInfo.recoverySource.target.path.replace('/\\/g', '/')
async execute(params: SetupExecuteParams) {
if (params.recoverySource?.type === 'backup') {
const target = params.recoverySource.target
if (target.type === 'cifs') {
target.path = target.path.replace('/\\/g', '/')
}
}
return this.rpcRequest<T.SetupProgress>({
method: 'setup.execute',
params: setupInfo,
params,
})
}
async initFollowLogs(): Promise<FollowLogsRes> {
return this.rpcRequest({ method: 'setup.logs.follow', params: {} })
async initFollowLogs() {
return this.rpcRequest<FollowLogsRes>({
method: 'setup.logs.follow',
params: {},
})
}
async complete(): Promise<T.SetupResult> {
const res = await this.rpcRequest<T.SetupResult>({
async complete() {
const res = await this.rpcRequest<SetupCompleteRes>({
method: 'setup.complete',
params: {},
})
@@ -113,23 +142,29 @@ export class LiveApiService extends ApiService {
}
}
async exit(): Promise<void> {
async exit() {
await this.rpcRequest<void>({
method: 'setup.exit',
params: {},
})
}
async restart(): Promise<void> {
async shutdown() {
await this.rpcRequest<void>({
method: 'setup.shutdown',
params: {},
})
}
async restart() {
await this.rpcRequest<void>({
method: 'setup.restart',
params: {},
})
}
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
const res = await this.http.rpcRequest<T>(opts)
private async rpcRequest<T>(opts: RPCOptions, url?: string): Promise<T> {
const res = await this.http.rpcRequest<T>(opts, url)
const rpcRes = res.body
if (isRpcError(rpcRes)) {
@@ -139,9 +174,3 @@ export class LiveApiService extends ApiService {
return rpcRes.result
}
}
function isCifsSource(
source: T.BackupTargetFS | null,
): source is T.Cifs & { type: 'cifs' } {
return !!(source as T.Cifs)?.hostname
}

View File

@@ -1,111 +1,33 @@
import { Injectable } from '@angular/core'
import {
DiskListResponse,
DiskInfo,
encodeBase64,
FollowLogsRes,
FullKeyboard,
pauseFor,
SetLanguageParams,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import * as jose from 'node-jose'
import { first, interval, map, Observable } from 'rxjs'
import { interval, map, Observable } from 'rxjs'
import { ApiService } from './api.service'
import {
SetupStatusRes,
InstallOsParams,
InstallOsRes,
AttachParams,
SetupExecuteParams,
SetupCompleteRes,
EchoReq,
} from '../types'
@Injectable({
providedIn: 'root',
})
export class MockApiService extends ApiService {
// fullProgress$(): Observable<T.FullProgress> {
// const phases = [
// {
// name: 'Preparing Data',
// progress: null,
// },
// {
// name: 'Transferring Data',
// progress: null,
// },
// {
// name: 'Finalizing Setup',
// progress: null,
// },
// ]
// return from(phases).pipe(
// switchScan((acc, val, i) => {}, { overall: null, phases }),
// )
// }
// namedProgress$(namedProgress: T.NamedProgress): Observable<T.NamedProgress> {
// return of(namedProgress).pipe(startWith(namedProgress))
// }
// progress$(progress: T.Progress): Observable<T.Progress> {}
// websocket
// oldMockProgress$(): Promise<T.FullProgress> {
// const numPhases = PROGRESS.phases.length
// return of(PROGRESS).pipe(
// switchMap(full =>
// from(PROGRESS.phases).pipe(
// mergeScan((full, phase, i) => {
// if (
// !phase.progress ||
// typeof phase.progress !== 'object' ||
// !phase.progress.total
// ) {
// full.phases[i].progress = true
// if (
// full.overall &&
// typeof full.overall === 'object' &&
// full.overall.total
// ) {
// const step = full.overall.total / numPhases
// full.overall.done += step
// }
// return of(full).pipe(delay(2000))
// } else {
// const total = phase.progress.total
// const step = total / 4
// let done = phase.progress.done
// return interval(1000).pipe(
// takeWhile(() => done < total),
// map(() => {
// done += step
// console.error(done)
// if (
// full.overall &&
// typeof full.overall === 'object' &&
// full.overall.total
// ) {
// const step = full.overall.total / numPhases / 4
// full.overall.done += step
// }
// if (done === total) {
// full.phases[i].progress = true
// if (i === numPhases - 1) {
// full.overall = true
// }
// }
// return full
// }),
// )
// }
// }, full),
// ),
// ),
// )
// }
private statusIndex = 0
private installCompleted = false
openWebsocket$<T>(guid: string): Observable<T> {
if (guid === 'logs-guid') {
@@ -117,24 +39,13 @@ export class MockApiService extends ApiService {
})),
) as Observable<T>
} else if (guid === 'progress-guid') {
// @TODO Matt mock progress
return interval(1000).pipe(
first(),
map(() => ({
overall: true,
phases: [
{
name: 'Preparing Data',
progress: true,
},
{
name: 'Transferring Data',
progress: true,
},
{
name: 'Finalizing Setup',
progress: true,
},
{ name: 'Preparing Data', progress: true },
{ name: 'Transferring Data', progress: true },
{ name: 'Finalizing Setup', progress: true },
],
})),
) as Observable<T>
@@ -143,40 +54,44 @@ export class MockApiService extends ApiService {
}
}
private statusIndex = 0
async getStatus(): Promise<T.SetupStatusRes | null> {
await pauseFor(1000)
async echo(params: EchoReq, url: string): Promise<string> {
if (url) {
const num = Math.floor(Math.random() * 10) + 1
if (num > 8) return params.message
throw new Error()
}
await pauseFor(500)
return params.message
}
async getStatus(): Promise<SetupStatusRes> {
await pauseFor(500)
this.statusIndex++
switch (this.statusIndex) {
case 2:
return {
status: 'running',
progress: PROGRESS,
guid: 'progress-guid',
}
case 3:
return {
status: 'complete',
torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'],
hostname: 'adjective-noun',
lanAddress: 'https://adjective-noun.local',
rootCa: encodeBase64(rootCA),
}
default:
return null
if (this.statusIndex === 1) {
return { status: 'needs-install', keyboard: null }
// return {
// status: 'incomplete',
// attach: false,
// guid: 'mock-data-guid',
// keyboard: null,
// }
}
if (this.statusIndex > 3) {
return { status: 'complete' }
}
return {
status: 'running',
progress: PROGRESS,
guid: 'progress-guid',
}
}
async getPubKey(): Promise<void> {
await pauseFor(1000)
// randomly generated
// const keystore = jose.JWK.createKeyStore()
// this.pubkey = await keystore.generate('EC', 'P-256')
// generated from backend
await pauseFor(300)
this.pubkey = await jose.JWK.asKey({
kty: 'EC',
crv: 'P-256',
@@ -185,88 +100,28 @@ export class MockApiService extends ApiService {
})
}
async getDrives(): Promise<DiskListResponse> {
await pauseFor(1000)
return [
{
logicalname: '/dev/nvme0n1p3',
vendor: 'Unknown Vendor',
model: 'Samsung SSD - 970 EVO Plus 2TB',
partitions: [
{
logicalname: 'pabcd',
label: null,
capacity: 1979120929996,
used: null,
startOs: {
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
version: '0.2.17',
timestamp: new Date().toISOString(),
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: null,
},
],
capacity: 1979120929996,
guid: 'uuid-uuid-uuid-uuid',
},
{
logicalname: 'dcba',
vendor: 'CT1000MX',
model: '500SSD1',
partitions: [
{
logicalname: 'pbcba',
label: null,
capacity: 73264762332,
used: null,
startOs: {
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
version: '0.2.17',
timestamp: new Date().toISOString(),
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: null,
},
],
capacity: 1000190509056,
guid: null,
},
{
logicalname: '/dev/sda',
vendor: 'ASMT',
model: '2115',
partitions: [
{
logicalname: 'pbcba',
label: null,
capacity: 73264762332,
used: null,
startOs: {
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
version: '0.2.17',
timestamp: new Date().toISOString(),
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: 'guid-guid-guid-guid',
},
],
capacity: 1000190509,
guid: null,
},
]
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
}
async installOs(params: InstallOsParams): Promise<InstallOsRes> {
await pauseFor(2000)
this.installCompleted = true
return {
guid: 'mock-data-guid',
attach: !params.dataDrive.wipe,
}
}
async verifyCifs(
@@ -282,21 +137,29 @@ export class MockApiService extends ApiService {
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: '',
},
'9876-5432-1234-5671': {
hostname: 'adjective-noun',
version: '0.3.6',
timestamp: new Date().toISOString(),
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: '',
},
}
}
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
async attach(params: AttachParams): Promise<T.SetupProgress> {
await pauseFor(1000)
this.statusIndex = 1 // Jump to running state
return {
progress: PROGRESS,
guid: 'progress-guid',
}
}
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
async execute(params: SetupExecuteParams): Promise<T.SetupProgress> {
await pauseFor(1000)
this.statusIndex = 1 // Jump to running state
return {
progress: PROGRESS,
guid: 'progress-guid',
@@ -304,33 +167,113 @@ export class MockApiService extends ApiService {
}
async initFollowLogs(): Promise<FollowLogsRes> {
await pauseFor(1000)
await pauseFor(500)
return {
startCursor: 'fakestartcursor',
guid: 'logs-guid',
}
}
async complete(): Promise<T.SetupResult> {
await pauseFor(1000)
async complete(): Promise<SetupCompleteRes> {
await pauseFor(500)
return {
torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'],
hostname: 'adjective-noun',
lanAddress: 'https://adjective-noun.local',
rootCa: encodeBase64(rootCA),
rootCa: encodeBase64(ROOT_CA),
needsRestart: this.installCompleted,
}
}
async exit(): Promise<void> {
await pauseFor(1000)
await pauseFor(500)
}
async shutdown(): Promise<void> {
await pauseFor(500)
}
async restart(): Promise<void> {
await pauseFor(1000)
await pauseFor(500)
}
}
const rootCA = `-----BEGIN CERTIFICATE-----
const MOCK_DISKS: DiskInfo[] = [
{
logicalname: '/dev/sda',
vendor: 'Samsung',
model: 'SSD 970 EVO Plus',
partitions: [
{
logicalname: '/dev/sda1',
label: null,
capacity: 500000000000,
used: null,
startOs: {},
guid: null,
},
],
capacity: 500000000000,
guid: null,
},
{
logicalname: '/dev/sdb',
vendor: 'Crucial',
model: 'MX500',
partitions: [
{
logicalname: '/dev/sdb1',
label: null,
capacity: 1000000000000,
used: null,
startOs: {
'1234-5678-9876-5432': {
hostname: 'existing-server',
version: '0.3.6',
timestamp: new Date().toISOString(),
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: 'existing-guid',
},
],
capacity: 1000000000000,
guid: 'existing-guid',
},
{
logicalname: '/dev/sdc',
vendor: 'WD',
model: 'Blue SN570',
partitions: [
{
logicalname: '/dev/sdc1',
label: 'Backup',
capacity: 2000000000000,
used: 500000000000,
startOs: {
'backup-server-id': {
hostname: 'backup-server',
version: '0.3.5',
timestamp: new Date(Date.now() - 86400000).toISOString(),
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: '',
},
},
guid: null,
},
],
capacity: 2000000000000,
guid: null,
},
]
const PROGRESS: T.FullProgress = {
overall: null,
phases: [],
}
const ROOT_CA = `-----BEGIN CERTIFICATE-----
MIIDpzCCAo+gAwIBAgIRAIIuOarlQETlUQEOZJGZYdIwDQYJKoZIhvcNAQELBQAw
bTELMAkGA1UEBhMCVVMxFTATBgNVBAoMDEV4YW1wbGUgQ29ycDEOMAwGA1UECwwF
U2FsZXMxCzAJBgNVBAgMAldBMRgwFgYDVQQDDA93d3cuZXhhbXBsZS5jb20xEDAO
@@ -352,8 +295,3 @@ Rf3ZOPm9QP92YpWyYDkfAU04xdDo1vR0MYjKPkl4LjRqSU/tcCJnPMbJiwq+bWpX
2WJoEBXB/p15Kn6JxjI0ze2SnSI48JZ8it4fvxrhOo0VoLNIuCuNXJOwU17Rdl1W
YJidaq7je6k18AdgPA0Kh8y1XtfUH3fTaVw4
-----END CERTIFICATE-----`
const PROGRESS = {
overall: null,
phases: [],
}

View File

@@ -1,6 +1,28 @@
import { inject, Injectable } from '@angular/core'
import { ApiService } from './api.service'
import { T } from '@start9labs/start-sdk'
import { ApiService } from './api.service'
export type SetupType = 'fresh' | 'restore' | 'attach' | 'transfer'
export type RecoverySource =
| {
type: 'migrate'
guid: string
}
| {
type: 'backup'
target:
| { type: 'disk'; logicalname: string }
| {
type: 'cifs'
hostname: string
path: string
username: string
password: string | null
}
serverId: string
password: string // plaintext, will be encrypted before sending
}
@Injectable({
providedIn: 'root',
@@ -8,34 +30,68 @@ import { T } from '@start9labs/start-sdk'
export class StateService {
private readonly api = inject(ApiService)
kiosk?: boolean
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
recoverySource?: T.RecoverySource<string>
// Determined at app init
kiosk = false
async importDrive(guid: string, password: string): Promise<void> {
// Set during install flow, or loaded from status response
language = ''
keyboard = ''
// From install response or status response (incomplete)
dataDriveGuid = ''
attach = false
// Set during setup flow
setupType?: SetupType
recoverySource?: RecoverySource
/**
* Called for attach flow (existing data drive)
*/
async attachDrive(password: string | null): Promise<void> {
await this.api.attach({
guid,
startOsPassword: await this.api.encrypt(password),
kiosk: this.kiosk,
guid: this.dataDriveGuid,
password: password ? await this.api.encrypt(password) : null,
})
}
async setupEmbassy(
storageLogicalname: string,
password: string,
): Promise<void> {
/**
* Called for fresh, restore, and transfer flows
* password is required for fresh, optional for restore/transfer
*/
async executeSetup(password: string | null): Promise<void> {
let recoverySource: T.RecoverySource<T.EncryptedWire> | null = null
if (this.recoverySource) {
if (this.recoverySource.type === 'migrate') {
recoverySource = this.recoverySource
} else {
// backup type - need to encrypt the backup password
recoverySource = {
type: 'backup',
target: this.recoverySource.target,
serverId: this.recoverySource.serverId,
password: await this.api.encrypt(this.recoverySource.password),
}
}
}
await this.api.execute({
startOsLogicalname: storageLogicalname,
startOsPassword: await this.api.encrypt(password),
recoverySource: this.recoverySource
? this.recoverySource.type === 'migrate'
? this.recoverySource
: {
...this.recoverySource,
password: await this.api.encrypt(this.recoverySource.password),
}
: null,
kiosk: this.kiosk,
guid: this.dataDriveGuid,
password: password ? await this.api.encrypt(password) : null,
recoverySource,
})
}
/**
* Reset state for a fresh start
*/
reset(): void {
this.language = ''
this.keyboard = ''
this.dataDriveGuid = ''
this.attach = false
this.setupType = undefined
this.recoverySource = undefined
}
}

View File

@@ -0,0 +1,94 @@
import {
DiskInfo,
FullKeyboard,
PartitionInfo,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
// === Echo ===
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 {
osDrive: string // e.g. /dev/sda
dataDrive: {
logicalname: string // e.g. /dev/sda, /dev/sdb3
wipe: boolean
}
}
export interface InstallOsRes {
guid: string // data drive guid
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 ===
export type StartOSDiskInfoWithId = StartOSDiskInfo & {
id: string
}
export type StartOSDiskInfoFull = StartOSDiskInfoWithId & {
partition: PartitionInfo
drive: DiskInfo
}

View File

@@ -19,16 +19,29 @@ router-outlet + * {
flex-direction: column;
align-items: center;
box-sizing: border-box;
justify-content: center;
padding: 2rem;
margin: 0 auto;
[tuiCardLarge] {
width: 100%;
background: var(--tui-background-elevation-2);
margin: auto;
margin-top: 2rem;
}
}
.inline-title {
display: inline-flex;
align-items: center;
gap: 0.5rem;
:first-child {
width: 2rem;
height: 2rem;
}
}
button:disabled {
opacity: var(--tui-disabled-opacity);
pointer-events: none;
@@ -74,4 +87,4 @@ h2 {
[tuiCell]:not(:last-of-type) {
box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal);
}
}

View File

@@ -1,28 +0,0 @@
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { Component, Input } from '@angular/core'
import { UnitConversionPipesModule } from '../pipes/unit-conversion/unit-conversion.module'
@Component({
selector: 'button[drive]',
template: `
<tui-icon icon="@tui.save" />
<span tuiTitle>
<strong>{{ drive.logicalname }}</strong>
<span tuiSubtitle>
{{ drive.vendor || 'Unknown Vendor' }} -
{{ drive.model || 'Unknown Model' }}
</span>
<span tuiSubtitle>Capacity: {{ drive.capacity | convertBytes }}</span>
<ng-content />
</span>
`,
imports: [TuiIcon, TuiTitle, UnitConversionPipesModule],
})
export class DriveComponent {
@Input() drive!: {
logicalname: string | null
vendor: string | null
model: string | null
capacity: number
}
}

View File

@@ -99,6 +99,7 @@ export default {
101: 'Sie haben nicht gespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?',
102: 'Verlassen',
103: 'Sind Sie sicher?',
104: 'Neues Netzwerk-Gateway',
108: 'Öffentlich',
109: 'privat',
111: 'Keine Onion-Domains',
@@ -493,6 +494,7 @@ export default {
516: 'Empfohlen',
517: 'Möchten Sie diese Aufgabe wirklich verwerfen?',
518: 'Verwerfen',
519: 'Muss eine gültige IPv4- oder IPv6-Adresse mit optionalem Port sein',
520: 'Update verfügbar',
521: 'Um das Problem zu beheben, siehe',
522: 'SDK version',
@@ -582,10 +584,10 @@ export default {
612: 'Grund',
613: 'Private Gateways für die StartOS-Benutzeroberfläche können nicht deaktiviert werden',
614: 'Root-CA',
615: 'DHCP-Server',
616: 'DHCP-Server können nicht bearbeitet werden',
615: 'Die von Ihrem Router bereitgestellten DNS-Server verwenden',
616: 'Manuell festgelegte DNS-Server verwenden',
617: 'Statisch',
618: 'Statische Server',
618: 'Server',
619: 'Warnung. StartOS verwendet derzeit das folgende Gateway für DNS',
620: 'Wenn Sie dieses Gateway für die Auflösung privater Domains verwenden möchten, legen Sie alternative statische DNS-Server mit dem obigen Formular fest.',
621: 'Einen Dienst paketieren',
@@ -596,5 +598,86 @@ export default {
626: 'Hochladen',
627: 'UI öffnen',
628: 'In Zwischenablage kopiert',
629: 'Die Liste ist leer',
629: 'StartOS wird installiert',
630: 'Wähle deine Sprache',
631: 'Tastaturlayout auswählen',
632: 'Tastatur',
633: 'Einrichtungsablauf auswählen',
634: 'Neu beginnen',
635: 'Einen brandneuen Server einrichten',
636: 'Aus Backup wiederherstellen',
637: 'StartOS-Daten aus einem verschlüsselten Backup wiederherstellen',
638: 'Daten von einem bestehenden StartOS-Datenträger übertragen',
639: 'Laufwerke auswählen',
640: 'Keine Laufwerke gefunden. Bitte schließen Sie ein Laufwerk an und klicken Sie auf Aktualisieren.',
641: 'OS-Laufwerk',
642: 'Datenlaufwerk',
643: 'StartOS-Daten erkannt',
644: 'Daten auf dem OS-Laufwerk könnten überschrieben werden.',
645: 'Ihre StartOS-Daten auf dem Datenlaufwerk bleiben erhalten.',
646: 'Daten auf diesem Laufwerk werden überschrieben.',
647: 'Daten auf beiden Laufwerken werden überschrieben.',
648: 'Backup auswählen',
649: 'Wählen Sie das StartOS-Backup aus, das Sie wiederherstellen möchten',
650: 'Netzwerk-Backup öffnen',
651: 'Physische Backups',
652: 'Keine physischen Backups',
653: 'Netzwerkordner verbinden',
654: 'Netzwerk-Backup auswählen',
655: 'Backup entsperren',
656: 'Daten übertragen',
657: 'Wählen Sie das Laufwerk mit Ihren bestehenden StartOS-Daten aus',
658: 'Laufwerk auswählen',
659: 'Keine StartOS-Datenlaufwerke gefunden',
660: 'Master-Passwort festlegen',
661: 'Neues Passwort festlegen (optional)',
662: 'Machen Sie es gut. Schreiben Sie es auf.',
663: 'Überspringen, um Ihr bestehendes Passwort beizubehalten.',
664: 'Passwort eingeben',
665: 'Passwort bestätigen',
666: 'Fertigstellen',
667: 'Einrichtung wird gestartet',
670: 'Warten Sie 12 Minuten und aktualisieren Sie die Seite',
672: 'Einrichtung abgeschlossen!',
673: 'Sie können Ihr Backup-Laufwerk entfernen',
674: 'Sie können Ihr Übertragungs-Laufwerk entfernen',
675: 'http://start.local war nur für die Einrichtung gedacht. Es funktioniert nicht mehr.',
676: 'Adressinformationen herunterladen',
677: 'Enthält die permanente lokale Adresse Ihres Servers und die Root-CA',
678: 'USB entfernt',
679: 'Entfernen Sie das USB-Installationsmedium aus Ihrem Server',
680: 'Server neu starten',
681: 'Warten, bis der Server wieder online ist',
682: 'Server ist wieder online',
683: 'Starten Sie Ihren Server neu, um die Einrichtung abzuschließen',
684: 'Lokale Adresse öffnen',
685: 'Weiter zur Anmeldung',
686: 'Zur StartOS-Anmeldeseite wechseln',
687: 'Der Server ist nicht wieder online gegangen. Bitte überprüfen Sie Ihren Server und versuchen Sie, manuell darauf zuzugreifen.',
691: 'Dieses Feld ist erforderlich',
692: 'Verbindung fehlgeschlagen',
693: 'Verbindung zum Netzwerkordner nicht möglich. Stellen Sie sicher, dass (1) der Zielcomputer mit dem LAN verbunden ist, (2) der Zielordner freigegeben ist und (3) Hostname, Pfad und Anmeldedaten korrekt sind.',
694: 'Dieses Laufwerk enthält bestehende StartOS-Daten.',
695: 'um Ihre Daten zu behalten.',
696: 'um zu verwerfen',
697: 'Geben Sie das Passwort ein, das zum Verschlüsseln dieses Backups verwendet wurde.',
698: 'Mehrere Backups gefunden. Wählen Sie aus, welches wiederhergestellt werden soll.',
699: 'Backups',
700: 'Das Laufwerk, auf dem das StartOS-Betriebssystem installiert wird.',
701: 'Das Laufwerk, auf dem Ihre StartOS-Daten (Dienste, Einstellungen usw.) gespeichert werden. Dies kann dasselbe wie das OS-Laufwerk oder ein separates Laufwerk sein.',
702: 'Versuchen Sie nach der Datenübertragung von diesem Laufwerk nicht, erneut als Start9-Server davon zu booten. Dies kann zu Fehlfunktionen von Diensten, Datenbeschädigung oder Geldverlust führen.',
703: 'Muss mindestens 12 Zeichen lang sein',
704: 'Darf höchstens 64 Zeichen lang sein',
705: 'Passwörter stimmen nicht überein',
706: 'Beibehalten',
707: 'Überschreiben',
708: 'Entsperren',
709: 'Laufwerk',
710: 'Übertragen',
711: 'Die Liste ist leer',
712: 'Jetzt neu starten',
713: 'Später',
714: 'Installation abgeschlossen!',
715: 'StartOS wurde erfolgreich installiert.',
716: 'Weiter zur Einrichtung',
} satisfies i18n

View File

@@ -56,7 +56,7 @@ export const ENGLISH = {
'Beginning shutdown': 57,
'Add': 58,
'Ok': 59,
'french': 60,
'fr_FR': 60,
'This value cannot be changed once set': 61,
'Continue': 62,
'Click or drop file here': 63,
@@ -98,6 +98,7 @@ export const ENGLISH = {
'You have unsaved changes. Are you sure you want to leave?': 101,
'Leave': 102,
'Are you sure?': 103,
'New gateway': 104, // as in, a network gateway
'public': 108,
'private': 109,
'No Tor domains': 111,
@@ -462,10 +463,10 @@ export const ENGLISH = {
'StartOS UI': 485,
'WiFi': 486,
'Documentation': 487, // as in, a website to view documentation
'spanish': 488,
'polish': 489,
'german': 490,
'english': 491,
'es_ES': 488,
'pl_PL': 489,
'de_DE': 490,
'en_US': 491,
'Start Menu': 492,
'Install Progress': 493,
'Downloading': 494,
@@ -492,6 +493,7 @@ export const ENGLISH = {
'Recommended': 516, // as in, we recommend this
'Are you sure you want to dismiss this task?': 517,
'Dismiss': 518, // as in, dismiss or delete a task
'Must be a valid IPv4 or Ipv6 address with optional port': 519,
'Update available': 520,
'To resolve the issue, refer to': 521,
'SDK version': 522,
@@ -581,10 +583,10 @@ export const ENGLISH = {
'Reason': 612, // as in, an explanation for something
'Cannot disable private gateways for StartOS UI': 613,
'Root CA': 614, // as in, the unique, fixed-length digital identifier generated from a certificate's data using a cryptographic hash function
'DHCP Servers': 615,
'Cannot edit DHCP servers': 616,
'Use the DNS servers provided by your router': 615,
'Use DNS servers you specify manually': 616,
'Static': 617, // as in, unchanging
'Static Servers': 618, // as in, servers that do not change
'Servers': 618, // as in, computer servers
'Warning. StartOS is currently using the following gateway for DNS': 619,
'If you intend to use this gateway for private domain resolution, set alternative static DNS servers using the form above.': 620,
'Package a service': 621, // as in, package a software application for an operating system
@@ -595,5 +597,87 @@ export const ENGLISH = {
'Upload': 626, // as in, upload a file
'Open UI': 627, // as in, upload a file
'Copied to clipboard': 628,
'The list is empty': 629,
} as Record<any, any>
// Setup wizard keys
'Installing StartOS': 629,
'Select your language': 630,
'Select Keyboard Layout': 631,
'Keyboard': 632,
'Select Setup Flow': 633,
'Start Fresh': 634,
'Set up a brand new server': 635,
'Restore from Backup': 636,
'Restore StartOS data from an encrypted backup': 637,
'Transfer data from an existing StartOS data drive': 638,
'Select Drives': 639, // as in storage devices
'No drives found. Please connect a drive and click Refresh.': 640,
'OS Drive': 641,
'Data Drive': 642,
'StartOS Data Detected': 643,
'Data on the OS drive may be overwritten.': 644,
'your StartOS data on the data drive will be preserved.': 645,
'Data on this drive will be overwritten.': 646,
'Data on both drives will be overwritten.': 647,
'Select Backup': 648,
'Select the StartOS backup you want to restore': 649,
'Open Network Backup': 650, // as in, a backup stored on a networked device
'Physical Backups': 651, // as in, a backup stored on a physical drive
'No physical backups': 652,
'Connect Network Folder': 653,
'Select Network Backup': 654,
'Unlock Backup': 655,
'Transfer Data': 656,
'Select the drive containing your existing StartOS data': 657,
'Select Drive': 658,
'No StartOS data drives found': 659,
'Set Master Password': 660,
'Set New Password (Optional)': 661,
'Make it good. Write it down.': 662,
'Skip to keep your existing password.': 663,
'Enter Password': 664,
'Confirm Password': 665,
'Finish': 666,
'Starting setup': 667,
'Wait 1-2 minutes and refresh the page': 670,
'Setup Complete!': 672,
'You can unplug your backup drive': 673,
'You can unplug your transfer drive': 674,
'http://start.local was for setup only. It will no longer work.': 675,
'Download Address Info': 676,
"Contains your server's permanent local address and Root CA": 677,
'USB Removed': 678,
'Remove the USB installation media from your server': 679,
'Restart Server': 680,
'Waiting for server to come back online': 681,
'Server is back online': 682,
'Restart your server to complete setup': 683,
'Open Local Address': 684,
'Continue to Login': 685,
'Proceed to the StartOS login screen': 686,
'Server did not come back online. Please check your server and try accessing it manually.': 687,
'This field is required': 691,
'Connection Failed': 692,
'Unable to connect to network folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.': 693,
'This drive contains existing StartOS data.': 694,
'to keep your data.': 695,
'to discard': 696,
'Enter the password that was used to encrypt this backup.': 697,
'Multiple backups found. Select which one to restore.': 698,
'Backups': 699,
'The drive where the StartOS operating system will be installed.': 700,
'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.': 701,
'After transferring data from this drive, do not attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.': 702,
'Must be 12 characters or greater': 703,
'Must be 64 character or less': 704,
'Passwords do not match': 705,
'Preserve': 706,
'Overwrite': 707,
'Unlock': 708,
'Drive': 709, // the noun, a storage device
'Transfer': 710, // the verb
'The list is empty': 711,
'Restart now': 712,
'Later': 713, // as in, (do it) later
'Installation Complete!': 714,
'StartOS has been installed successfully.': 715,
'Continue to Setup': 716,
} as const

View File

@@ -99,6 +99,7 @@ export default {
101: 'Tienes cambios no guardados. ¿Estás seguro de que deseas salir?',
102: 'Salir',
103: '¿Estás seguro?',
104: 'Nueva puerta de enlace de red',
108: 'público',
109: 'privado',
111: 'Sin dominios onion',
@@ -493,6 +494,7 @@ export default {
516: 'Recomendado',
517: '¿Estás seguro de que deseas descartar esta tarea?',
518: 'Descartar',
519: 'Debe ser una dirección IPv4 o IPv6 válida con puerto opcional',
520: 'Actualización disponible',
521: 'Para resolver el problema, consulta',
522: 'Versión de SDK',
@@ -582,10 +584,10 @@ export default {
612: 'Razón',
613: 'No se pueden deshabilitar las puertas de enlace privadas para la interfaz de usuario de StartOS',
614: 'CA raíz',
615: 'Servidores DHCP',
616: 'No se pueden editar los servidores DHCP',
615: 'Usar los servidores DNS proporcionados por su router',
616: 'Usar servidores DNS que especifique manualmente',
617: 'Estático',
618: 'Servidores estáticos',
618: 'Servidores',
619: 'Advertencia. StartOS está utilizando actualmente la siguiente puerta de enlace para DNS',
620: 'Si deseas usar esta puerta de enlace para la resolución de dominios privados, configura servidores DNS estáticos alternativos usando el formulario anterior.',
621: 'Empaquetar un servicio',
@@ -596,5 +598,86 @@ export default {
626: 'Subir',
627: 'Abrir UI',
628: 'Copiado al portapapeles',
629: 'La lista está vacía',
629: 'Instalando StartOS',
630: 'Selecciona tu idioma',
631: 'Seleccionar distribución del teclado',
632: 'Teclado',
633: 'Seleccionar flujo de configuración',
634: 'Empezar desde cero',
635: 'Configurar un servidor completamente nuevo',
636: 'Restaurar desde copia de seguridad',
637: 'Restaurar datos de StartOS desde una copia de seguridad cifrada',
638: 'Transferir datos desde una unidad de datos StartOS existente',
639: 'Seleccionar unidades',
640: 'No se encontraron unidades. Conecte una unidad y haga clic en Actualizar.',
641: 'Unidad del sistema operativo',
642: 'Unidad de datos',
643: 'Datos de StartOS detectados',
644: 'Los datos de la unidad del sistema operativo pueden sobrescribirse.',
645: 'Sus datos de StartOS en la unidad de datos se conservarán.',
646: 'Los datos de esta unidad se sobrescribirán.',
647: 'Los datos de ambas unidades se sobrescribirán.',
648: 'Seleccionar copia de seguridad',
649: 'Seleccione la copia de seguridad de StartOS que desea restaurar',
650: 'Abrir copia de seguridad de red',
651: 'Copias de seguridad físicas',
652: 'No hay copias de seguridad físicas',
653: 'Conectar carpeta de red',
654: 'Seleccionar copia de seguridad de red',
655: 'Desbloquear copia de seguridad',
656: 'Transferir datos',
657: 'Seleccione la unidad que contiene sus datos StartOS existentes',
658: 'Seleccionar unidad',
659: 'No se encontraron unidades de datos StartOS',
660: 'Establecer contraseña maestra',
661: 'Establecer nueva contraseña (opcional)',
662: 'Que sea buena. Escríbala.',
663: 'Omitir para mantener su contraseña existente.',
664: 'Introducir contraseña',
665: 'Confirmar contraseña',
666: 'Finalizar',
667: 'Iniciando configuración',
670: 'Espere 12 minutos y actualice la página',
672: '¡Configuración completa!',
673: 'Puede desconectar su unidad de copia de seguridad',
674: 'Puede desconectar su unidad de transferencia',
675: 'http://start.local era solo para la configuración. Ya no funcionará.',
676: 'Descargar información de direcciones',
677: 'Contiene la dirección local permanente de su servidor y la CA raíz',
678: 'USB retirado',
679: 'Retire el medio de instalación USB de su servidor',
680: 'Reiniciar servidor',
681: 'Esperando a que el servidor vuelva a estar en línea',
682: 'El servidor ha vuelto a estar en línea',
683: 'Reinicie su servidor para completar la configuración',
684: 'Abrir dirección local',
685: 'Continuar al inicio de sesión',
686: 'Ir a la pantalla de inicio de sesión de StartOS',
687: 'El servidor no volvió a estar en línea. Verifique su servidor e intente acceder manualmente.',
691: 'Este campo es obligatorio',
692: 'Conexión fallida',
693: 'No se pudo conectar a la carpeta de red. Asegúrese de que (1) el equipo de destino esté conectado a la LAN, (2) la carpeta de destino esté compartida y (3) el nombre de host, la ruta y las credenciales sean correctos.',
694: 'Esta unidad contiene datos existentes de StartOS.',
695: 'para conservar sus datos.',
696: 'para descartar',
697: 'Introduzca la contraseña que se utilizó para cifrar esta copia de seguridad.',
698: 'Se encontraron varias copias de seguridad. Seleccione cuál restaurar.',
699: 'Copias de seguridad',
700: 'La unidad donde se instalará el sistema operativo StartOS.',
701: 'La unidad donde se almacenarán sus datos de StartOS (servicios, ajustes, etc.). Puede ser la misma que la unidad del sistema operativo o una unidad separada.',
702: 'Después de transferir datos desde esta unidad, no intente arrancar desde ella nuevamente como un servidor Start9. Esto puede provocar fallos en los servicios, corrupción de datos o pérdida de fondos.',
703: 'Debe tener 12 caracteres o más',
704: 'Debe tener 64 caracteres o menos',
705: 'Las contraseñas no coinciden',
706: 'Conservar',
707: 'Sobrescribir',
708: 'Desbloquear',
709: 'Unidad',
710: 'Transferir',
711: 'La lista está vacía',
712: 'Reiniciar ahora',
713: 'Más tarde',
714: '¡Instalación completada!',
715: 'StartOS se ha instalado correctamente.',
716: 'Continuar con la configuración',
} satisfies i18n

View File

@@ -99,6 +99,7 @@ export default {
101: 'Vous avez des modifications non enregistrées. Voulez-vous vraiment quitter ?',
102: 'Quitter',
103: 'Êtes-vous sûr ?',
104: 'Nouvelle passerelle réseau',
108: 'public',
109: 'privé',
111: 'Aucune domaine onion',
@@ -493,6 +494,7 @@ export default {
516: 'Recommandé',
517: 'Êtes-vous sûr de vouloir ignorer cette tâche ?',
518: 'Ignorer',
519: 'Doit être une adresse IPv4 ou IPv6 valide avec un port facultatif',
520: 'Mise à jour disponible',
521: 'Pour résoudre le problème, consultez',
522: 'Version de SDK',
@@ -582,10 +584,10 @@ export default {
612: 'Raison',
613: "Impossible de désactiver les passerelles privées pour l'interface utilisateur StartOS",
614: 'CA racine',
615: 'Serveurs DHCP',
616: 'Impossible de modifier les serveurs DHCP',
615: 'Utiliser les serveurs DNS fournis par votre routeur',
616: 'Utiliser des serveurs DNS que vous spécifiez manuellement',
617: 'Statique',
618: 'Serveurs statiques',
618: 'Serveurs',
619: 'Avertissement. StartOS utilise actuellement la passerelle suivante pour le DNS',
620: 'Si vous souhaitez utiliser cette passerelle pour la résolution de domaines privés, définissez des serveurs DNS statiques alternatifs à laide du formulaire ci-dessus.',
621: 'Emballer un service',
@@ -596,5 +598,86 @@ export default {
626: 'Téléverser',
627: 'Ouvrir UI',
628: 'Copié dans le presse-papiers',
629: 'La liste est vide',
629: 'Installation de StartOS',
630: 'Sélectionnez votre langue',
631: 'Sélectionner la disposition du clavier',
632: 'Clavier',
633: 'Sélectionner le mode de configuration',
634: 'Démarrer à neuf',
635: 'Configurer un tout nouveau serveur',
636: 'Restaurer à partir dune sauvegarde',
637: 'Restaurer les données StartOS à partir dune sauvegarde chiffrée',
638: 'Transférer les données depuis un disque de données StartOS existant',
639: 'Sélectionner les disques',
640: 'Aucun disque trouvé. Veuillez connecter un disque et cliquer sur Actualiser.',
641: 'Disque du système',
642: 'Disque de données',
643: 'Données StartOS détectées',
644: 'Les données du disque système peuvent être écrasées.',
645: 'Vos données StartOS sur le disque de données seront conservées.',
646: 'Les données de ce disque seront écrasées.',
647: 'Les données des deux disques seront écrasées.',
648: 'Sélectionner une sauvegarde',
649: 'Sélectionnez la sauvegarde StartOS que vous souhaitez restaurer',
650: 'Ouvrir une sauvegarde réseau',
651: 'Sauvegardes physiques',
652: 'Aucune sauvegarde physique',
653: 'Connecter un dossier réseau',
654: 'Sélectionner une sauvegarde réseau',
655: 'Déverrouiller la sauvegarde',
656: 'Transférer les données',
657: 'Sélectionnez le disque contenant vos données StartOS existantes',
658: 'Sélectionner le disque',
659: 'Aucun disque de données StartOS trouvé',
660: 'Définir le mot de passe maître',
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',
665: 'Confirmer le mot de passe',
666: 'Terminer',
667: 'Démarrage de la configuration',
670: 'Attendez 1 à 2 minutes puis actualisez la page',
672: 'Configuration terminée !',
673: 'Vous pouvez débrancher votre disque de sauvegarde',
674: 'Vous pouvez débrancher votre disque de transfert',
675: 'http://start.local était réservé à la configuration. Il ne fonctionnera plus.',
676: 'Télécharger les informations dadresse',
677: 'Contient ladresse locale permanente de votre serveur et la CA racine',
678: 'USB retiré',
679: 'Retirez le support dinstallation USB de votre serveur',
680: 'Redémarrer le serveur',
681: 'En attente du retour en ligne du serveur',
682: 'Le serveur est de nouveau en ligne',
683: 'Redémarrez votre serveur pour terminer la configuration',
684: 'Ouvrir ladresse locale',
685: 'Continuer vers la connexion',
686: 'Accéder à lécran de connexion StartOS',
687: 'Le serveur nest pas revenu en ligne. Veuillez vérifier votre serveur et essayer dy accéder manuellement.',
691: 'Ce champ est requis',
692: 'Échec de la connexion',
693: 'Impossible de se connecter au dossier réseau. Assurez-vous que (1) lordinateur cible est connecté au LAN, (2) le dossier cible est partagé et (3) le nom dhôte, le chemin et les identifiants sont corrects.',
694: 'Ce disque contient des données StartOS existantes.',
695: 'pour conserver vos données.',
696: 'pour supprimer',
697: 'Saisissez le mot de passe utilisé pour chiffrer cette sauvegarde.',
698: 'Plusieurs sauvegardes trouvées. Sélectionnez celle à restaurer.',
699: 'Sauvegardes',
700: 'Le disque sur lequel le système dexploitation StartOS sera installé.',
701: 'Le disque sur lequel vos données StartOS (services, paramètres, etc.) seront stockées. Il peut sagir du même disque que le système ou dun disque séparé.',
702: 'Après le transfert des données depuis ce disque, nessayez pas de démarrer dessus à nouveau en tant que serveur Start9. Cela peut entraîner des dysfonctionnements des services, une corruption des données ou une perte de fonds.',
703: 'Doit comporter au moins 12 caractères',
704: 'Doit comporter au maximum 64 caractères',
705: 'Les mots de passe ne correspondent pas',
706: 'Conserver',
707: 'Écraser',
708: 'Déverrouiller',
709: 'Disque',
710: 'Transférer',
711: 'La liste est vide',
712: 'Redémarrer maintenant',
713: 'Plus tard',
714: 'Installation terminée !',
715: 'StartOS a été installé avec succès.',
716: 'Continuer vers la configuration',
} satisfies i18n

View File

@@ -99,6 +99,7 @@ export default {
101: 'Masz niezapisane zmiany. Czy na pewno chcesz opuścić tę stronę?',
102: 'Opuść',
103: 'Czy jesteś pewien?',
104: 'Nowa brama sieciowa',
108: 'publiczny',
109: 'prywatny',
111: 'Brak domeny onion',
@@ -493,6 +494,7 @@ export default {
516: 'Zalecane',
517: 'Czy na pewno chcesz odrzucić to zadanie?',
518: 'Odrzuć',
519: 'Musi być prawidłowym adresem IPv4 lub IPv6 z opcjonalnym portem',
520: 'Aktualizacja dostępna',
521: 'Aby rozwiązać problem, zapoznaj się z',
522: 'Wersja SDK',
@@ -582,10 +584,10 @@ export default {
612: 'Powód',
613: 'Nie można wyłączyć prywatnych bram dla interfejsu użytkownika StartOS',
614: 'głównego CA',
615: 'Serwery DHCP',
616: 'Nie można edytować serwerów DHCP',
615: 'Użyj serwerów DNS dostarczonych przez router',
616: 'Użyj serwerów DNS określonych ręcznie',
617: 'Statyczny',
618: 'Serwery statyczne',
618: 'Serwery',
619: 'Ostrzeżenie. StartOS obecnie używa następującej bramy do DNS',
620: 'Jeśli zamierzasz używać tej bramy do rozwiązywania domen prywatnych, ustaw alternatywne statyczne serwery DNS za pomocą powyższego formularza.',
621: 'Spakietuj usługę',
@@ -596,5 +598,86 @@ export default {
626: 'Prześlij',
627: 'Otwórz UI',
628: 'Skopiowano do schowka',
629: 'Lista jest pusta',
629: 'Instalowanie StartOS',
630: 'Wybierz swój język',
631: 'Wybierz układ klawiatury',
632: 'Klawiatura',
633: 'Wybierz tryb konfiguracji',
634: 'Rozpocznij od nowa',
635: 'Skonfiguruj zupełnie nowy serwer',
636: 'Przywróć z kopii zapasowej',
637: 'Przywróć dane StartOS z zaszyfrowanej kopii zapasowej',
638: 'Przenieś dane z istniejącego dysku danych StartOS',
639: 'Wybierz dyski',
640: 'Nie znaleziono dysków. Podłącz dysk i kliknij Odśwież.',
641: 'Dysk systemowy',
642: 'Dysk danych',
643: 'Wykryto dane StartOS',
644: 'Dane na dysku systemowym mogą zostać nadpisane.',
645: 'Twoje dane StartOS na dysku danych zostaną zachowane.',
646: 'Dane na tym dysku zostaną nadpisane.',
647: 'Dane na obu dyskach zostaną nadpisane.',
648: 'Wybierz kopię zapasową',
649: 'Wybierz kopię zapasową StartOS do przywrócenia',
650: 'Otwórz kopię zapasową sieciową',
651: 'Kopie zapasowe fizyczne',
652: 'Brak fizycznych kopii zapasowych',
653: 'Połącz folder sieciowy',
654: 'Wybierz kopię zapasową sieciową',
655: 'Odblokuj kopię zapasową',
656: 'Przenieś dane',
657: 'Wybierz dysk zawierający istniejące dane StartOS',
658: 'Wybierz dysk',
659: 'Nie znaleziono dysków danych StartOS',
660: 'Ustaw hasło główne',
661: 'Ustaw nowe hasło (opcjonalnie)',
662: 'Zadbaj o nie. Zapisz je.',
663: 'Pomiń, aby zachować istniejące hasło.',
664: 'Wprowadź hasło',
665: 'Potwierdź hasło',
666: 'Zakończ',
667: 'Rozpoczynanie konfiguracji',
670: 'Poczekaj 12 minuty i odśwież stronę',
672: 'Konfiguracja zakończona!',
673: 'Możesz odłączyć dysk kopii zapasowej',
674: 'Możesz odłączyć dysk transferowy',
675: 'http://start.local służył tylko do konfiguracji. Nie będzie już działać.',
676: 'Pobierz informacje adresowe',
677: 'Zawiera stały lokalny adres serwera oraz główny urząd certyfikacji (Root CA)',
678: 'USB usunięty',
679: 'Usuń instalacyjny nośnik USB z serwera',
680: 'Uruchom ponownie serwer',
681: 'Oczekiwanie na ponowne połączenie serwera',
682: 'Serwer jest ponownie online',
683: 'Uruchom ponownie serwer, aby zakończyć konfigurację',
684: 'Otwórz adres lokalny',
685: 'Przejdź do logowania',
686: 'Przejdź do ekranu logowania StartOS',
687: 'Serwer nie wrócił do trybu online. Sprawdź serwer i spróbuj uzyskać do niego dostęp ręcznie.',
691: 'To pole jest wymagane',
692: 'Nie udało się połączyć',
693: 'Nie można połączyć się z folderem sieciowym. Upewnij się, że (1) komputer docelowy jest podłączony do sieci LAN, (2) folder docelowy jest udostępniony oraz (3) nazwa hosta, ścieżka i dane logowania są poprawne.',
694: 'Ten dysk zawiera istniejące dane StartOS.',
695: 'aby zachować dane.',
696: 'aby odrzucić',
697: 'Wprowadź hasło użyte do zaszyfrowania tej kopii zapasowej.',
698: 'Znaleziono wiele kopii zapasowych. Wybierz, którą przywrócić.',
699: 'Kopie zapasowe',
700: 'Dysk, na którym zostanie zainstalowany system operacyjny StartOS.',
701: 'Dysk, na którym będą przechowywane dane StartOS (usługi, ustawienia itp.). Może to być ten sam dysk co systemowy lub oddzielny dysk.',
702: 'Po przeniesieniu danych z tego dysku nie próbuj ponownie uruchamiać z niego systemu jako serwer Start9. Może to spowodować nieprawidłowe działanie usług, uszkodzenie danych lub utratę środków.',
703: 'Musi mieć co najmniej 12 znaków',
704: 'Musi mieć maksymalnie 64 znaki',
705: 'Hasła nie są zgodne',
706: 'Zachowaj',
707: 'Nadpisz',
708: 'Odblokuj',
709: 'Dysk',
710: 'Przenieś',
711: 'Lista jest pusta',
712: 'Uruchom ponownie teraz',
713: 'Później',
714: 'Instalacja zakończona!',
715: 'StartOS został pomyślnie zainstalowany.',
716: 'Przejdź do konfiguracji',
} satisfies i18n

View File

@@ -6,7 +6,7 @@ import {
TuiLanguageSwitcherService,
} from '@taiga-ui/i18n'
import { ENGLISH } from './dictionaries/en'
import { i18nService } from './i18n.service'
import { i18nService, Languages } from './i18n.service'
export type i18nKey = keyof typeof ENGLISH
export type i18n = Record<(typeof ENGLISH)[i18nKey], string>
@@ -20,7 +20,7 @@ export const I18N_LOADER = new InjectionToken<
>('')
export const I18N_STORAGE = new InjectionToken<
(lang: TuiLanguageName) => Promise<void>
(lang: Languages) => Promise<void>
>('', {
factory: () => () => Promise.resolve(),
})

View File

@@ -1,6 +1,21 @@
import { inject, Injectable, signal } from '@angular/core'
import { TuiLanguageName, TuiLanguageSwitcherService } from '@taiga-ui/i18n'
import { I18N, I18N_LOADER, I18N_STORAGE } from './i18n.providers'
import { T } from '@start9labs/start-sdk'
export const languages = ['en_US', 'es_ES', 'de_DE', 'fr_FR', 'pl_PL'] as const
export type Languages = (typeof languages)[number]
/**
* Maps POSIX locale strings to TUI language names
*/
export const LANGUAGE_TO_TUI: Record<Languages, TuiLanguageName> = {
en_US: 'english',
es_ES: 'spanish',
de_DE: 'german',
fr_FR: 'french',
pl_PL: 'polish',
}
@Injectable({
providedIn: 'root',
@@ -12,20 +27,40 @@ export class i18nService extends TuiLanguageSwitcherService {
readonly loading = signal(false)
override setLanguage(language: TuiLanguageName = 'english'): void {
/**
* Current language as POSIX locale string
*/
get lang(): Languages {
return (
(Object.entries(LANGUAGE_TO_TUI).find(
([, tui]) => tui === this.language,
)?.[0] as Languages) || 'en_US'
)
}
localize(string: T.LocaleString): string {
if (typeof string === 'string') return string
return (
string[this.lang] ?? string['en_US'] ?? Object.values(string)[0] ?? ''
)
}
setLang(language: Languages = 'en_US'): void {
const tuiLang = LANGUAGE_TO_TUI[language]
const current = this.language
super.setLanguage(language)
super.setLanguage(tuiLang)
this.loading.set(true)
if (current === language) {
this.i18nLoader(language).then(value => {
if (current === tuiLang) {
this.i18nLoader(tuiLang).then(value => {
this.i18n.set(value)
this.loading.set(false)
})
} else {
this.store(language).then(() =>
this.i18nLoader(language).then(value => {
this.i18nLoader(tuiLang).then(value => {
this.i18n.set(value)
this.loading.set(false)
}),
@@ -33,12 +68,3 @@ export class i18nService extends TuiLanguageSwitcherService {
}
}
}
export const languages = [
'english',
'spanish',
'polish',
'german',
'french',
] as const
export type Languages = (typeof languages)[number]

View File

@@ -0,0 +1,16 @@
import { inject, Injectable, Pipe, PipeTransform } from '@angular/core'
import { i18nService } from './i18n.service'
import { T } from '@start9labs/start-sdk'
@Pipe({
name: 'localize',
pure: false,
})
@Injectable({ providedIn: 'root' })
export class LocalizePipe implements PipeTransform {
private readonly i18nService = inject(i18nService)
transform(string: T.LocaleString): string {
return this.i18nService.localize(string)
}
}

View File

@@ -8,7 +8,6 @@ export * from './classes/rpc-error'
export * from './components/initializing/logs-window.component'
export * from './components/initializing/initializing.component'
export * from './components/ticker.component'
export * from './components/drive.component'
export * from './components/markdown.component'
export * from './components/prompt.component'
export * from './components/server.component'
@@ -19,6 +18,7 @@ export * from './directives/safe-links.directive'
export * from './i18n/i18n.pipe'
export * from './i18n/i18n.providers'
export * from './i18n/i18n.service'
export * from './i18n/localize.pipe'
export * from './pipes/exver/exver.module'
export * from './pipes/exver/exver.pipe'
@@ -59,3 +59,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

@@ -10,7 +10,7 @@ export type AccessType =
export type WorkspaceConfig = {
gitHash: string
useMocks: boolean
// each key corresponds to a project and values adjust settings for that project, eg: ui, install-wizard, setup-wizard
// each key corresponds to a project and values adjust settings for that project, eg: ui, setup-wizard
ui: {
api: {
url: string

View File

@@ -0,0 +1,97 @@
import { LanguageCode } from './languages'
/**
* Keyboard layout codes (X11/Wayland)
*/
export type KeyboardLayout = 'us' | 'gb' | 'es' | 'latam' | 'de' | 'fr' | 'pl'
/**
* Keyboard keymap codes (console/TTY)
*/
export type KeyboardKeymap = 'us' | 'uk' | 'es' | 'la' | 'de' | 'fr' | 'pl'
/**
* Keyboard layout display names
*/
export type KeyboardName =
| 'US English'
| 'UK English'
| 'Spanish'
| 'Latin American'
| 'German'
| 'French'
| 'Polish'
/**
* Keyboard definition with layout and keymap
*/
export interface Keyboard {
layout: KeyboardLayout
keymap: KeyboardKeymap
name: KeyboardName
}
/**
* Full keyboard configuration for backend API
*/
export interface FullKeyboard {
layout: KeyboardLayout
keymap: KeyboardKeymap
model: string | null
variant: string | null
options: string[]
}
/**
* Keyboard layouts grouped by language code
*/
export const KEYBOARDS_BY_LANGUAGE: Record<LanguageCode, Keyboard[]> = {
en: [
{ layout: 'us', keymap: 'us', name: 'US English' },
{ layout: 'gb', keymap: 'uk', name: 'UK English' },
],
es: [
{ layout: 'es', keymap: 'es', name: 'Spanish' },
{ layout: 'latam', keymap: 'la', name: 'Latin American' },
],
de: [{ layout: 'de', keymap: 'de', name: 'German' }],
fr: [{ layout: 'fr', keymap: 'fr', name: 'French' }],
pl: [{ layout: 'pl', keymap: 'pl', name: 'Polish' }],
}
/**
* All available keyboard layouts
*/
export const ALL_KEYBOARDS: Keyboard[] = [
{ layout: 'us', keymap: 'us', name: 'US English' },
{ layout: 'gb', keymap: 'uk', name: 'UK English' },
{ layout: 'es', keymap: 'es', name: 'Spanish' },
{ layout: 'latam', keymap: 'la', name: 'Latin American' },
{ layout: 'de', keymap: 'de', name: 'German' },
{ layout: 'fr', keymap: 'fr', name: 'French' },
{ layout: 'pl', keymap: '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 languageLayouts = new Set(languageKeyboards.map(kb => kb.layout))
const otherKeyboards = ALL_KEYBOARDS.filter(
kb => !languageLayouts.has(kb.layout),
).sort((a, b) => a.name.localeCompare(b.name))
return [...languageKeyboards, ...otherKeyboards]
}
/**
* Get the display name for a keyboard layout.
*/
export function getKeyboardName(
layout: KeyboardLayout | string,
): KeyboardName | string {
const keyboard = ALL_KEYBOARDS.find(kb => kb.layout === layout)
if (keyboard) return keyboard.name
return layout // fallback to the layout 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: 'en_US', nativeName: 'English' },
{ code: 'es', name: 'es_ES', nativeName: 'Español' },
{ code: 'de', name: 'de_DE', nativeName: 'Deutsch' },
{ code: 'fr', name: 'fr_FR', nativeName: 'Français' },
{ code: 'pl', name: 'pl_PL', nativeName: 'Polski' },
]
/**
* Maps POSIX locale strings to ISO language codes
*/
export const LANGUAGE_TO_CODE: Record<Languages, LanguageCode> = {
en_US: 'en',
es_ES: 'es',
de_DE: 'de',
fr_FR: 'fr',
pl_PL: 'pl',
}
/**
* Params for setting language via API
*/
export interface SetLanguageParams {
language: Languages
}

View File

@@ -39,9 +39,9 @@ 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')
this.i18n.setLang(language || 'en_US')
})
}

View File

@@ -1,7 +1,7 @@
import { inject, provideAppInitializer } from '@angular/core'
import { UntypedFormBuilder } from '@angular/forms'
import { provideAnimations } from '@angular/platform-browser/animations'
import { Router } from '@angular/router'
import { ActivationStart, Router } from '@angular/router'
import { WA_LOCATION } from '@ng-web-apis/common'
import initArgon from '@start9labs/argon2'
import {
@@ -12,6 +12,7 @@ import {
I18N_PROVIDERS,
I18N_STORAGE,
i18nService,
Languages,
RELATIVE_URL,
VERSION,
WorkspaceConfig,
@@ -32,7 +33,7 @@ import {
TUI_DATE_VALUE_TRANSFORMER,
} from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter, identity, of, pairwise } from 'rxjs'
import { filter, identity, merge, of, pairwise } from 'rxjs'
import { ConfigService } from 'src/app/services/config.service'
import {
PATCH_CACHE,
@@ -115,11 +116,15 @@ export const APP_PROVIDERS = [
{
provide: TUI_DIALOGS_CLOSE,
useFactory: () =>
inject(StateService).pipe(
pairwise(),
filter(
([prev, curr]) =>
prev === 'running' && (curr === 'error' || curr === 'initializing'),
merge(
inject(Router).events.pipe(filter(e => e instanceof ActivationStart)),
inject(StateService).pipe(
pairwise(),
filter(
([prev, curr]) =>
prev === 'running' &&
(curr === 'error' || curr === 'initializing'),
),
),
),
},
@@ -128,7 +133,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

@@ -1,5 +1,6 @@
import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { Router, RouterLink } from '@angular/router'
import { invert } from '@start9labs/shared'
import { IST } from '@start9labs/start-sdk'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
@@ -36,6 +37,7 @@ import { HintPipe } from '../pipes/hint.pipe'
[placeholder]="spec.name"
[items]="items"
[(ngModel)]="selected"
(ngModelChange)="onChange($event)"
></select>
} @else {
<input
@@ -50,15 +52,27 @@ import { HintPipe } from '../pipes/hint.pipe'
@if (!mobile) {
<tui-data-list *tuiTextfieldDropdown>
@for (item of items; track item) {
<button
tuiOption
new
tuiFluidTypography
[style.white-space]="'nowrap'"
[value]="item"
>
{{ item }}
</button>
@if (inverted[item]?.startsWith('~')) {
<a
tuiOption
new
iconEnd="@tui.arrow-right"
tuiFluidTypography
[routerLink]="inverted[item]?.slice(1)"
>
{{ item }}
</a>
} @else {
<button
tuiOption
new
tuiFluidTypography
[style.white-space]="'nowrap'"
[value]="item"
>
{{ item }}
</button>
}
}
</tui-data-list>
}
@@ -70,6 +84,7 @@ import { HintPipe } from '../pipes/hint.pipe'
providers: [tuiFluidTypographyOptionsProvider({ max: 1 })],
imports: [
FormsModule,
RouterLink,
TuiTextfield,
TuiSelect,
TuiDataList,
@@ -81,8 +96,8 @@ import { HintPipe } from '../pipes/hint.pipe'
],
})
export class FormSelectComponent extends Control<IST.ValueSpecSelect, string> {
private readonly inverted = invert(this.spec.values)
protected readonly router = inject(Router)
protected readonly inverted = invert(this.spec.values)
protected readonly mobile = inject(TUI_IS_MOBILE)
protected readonly items = Object.values(this.spec.values)
protected readonly disabledItemHandler = (item: string) =>
@@ -101,4 +116,12 @@ export class FormSelectComponent extends Control<IST.ValueSpecSelect, string> {
set selected(value: string | null) {
this.value = (value && this.inverted[value]) || null
}
protected onChange(value: string) {
const mapped = this.inverted[value]
if (typeof mapped === 'string' && mapped.startsWith('~')) {
this.router.navigate([mapped.slice(1)])
}
}
}

View File

@@ -247,10 +247,10 @@ export class PublicDomainService {
),
values: gateways.reduce<Record<string, string>>(
(obj, gateway) => ({
...obj,
[gateway.id]: gateway.name || gateway.ipInfo.name,
...obj,
}),
{},
{ '~/system/gateways': this.i18n.transform('New gateway') },
),
default: '',
disabled: gateways

View File

@@ -14,7 +14,7 @@ import {
Exver,
ExverPipesModule,
i18nPipe,
isEmptyObject,
i18nService,
LoadingService,
sameUrl,
} from '@start9labs/shared'
@@ -123,6 +123,7 @@ export class MarketplaceControlsComponent {
private readonly router = inject(Router)
private readonly marketplace = inject(MarketplaceService)
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nService)
readonly pkg = input.required<Pick<MarketplacePkg, KEYS>>()
@@ -149,7 +150,7 @@ export class MarketplaceControlsComponent {
const originalUrl = localPkg?.registry || null
if (!localPkg) {
if (await this.alerts.alertInstall(this.pkg().alerts.install || '')) {
if (await this.alerts.alertInstall(this.i18n.localize(this.pkg().alerts.install || ''))) {
this.installOrUpload(currentUrl)
}
return
@@ -184,10 +185,7 @@ export class MarketplaceControlsComponent {
const packages = await getAllPackages(this.patch)
const breakages = dryUpdate({ id, version }, packages, this.exver)
if (
isEmptyObject(breakages) ||
(await this.alerts.alertBreakages(breakages))
) {
if (!breakages.length || (await this.alerts.alertBreakages(breakages))) {
this.installOrUpload(url)
}
}

View File

@@ -9,7 +9,7 @@ import {
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB } from '@start9labs/start-sdk'
import { ISB, utils } from '@start9labs/start-sdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
@@ -21,6 +21,14 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
// IPv4
const ipv4 =
/(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/
// IPv6 (your existing pattern)
const ipv6 =
/(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/
@Component({
template: `
<ng-container *title>
@@ -111,17 +119,11 @@ export default class SystemDnsComponent {
strategy: ISB.Value.union({
name: 'strategy',
default: 'dhcp',
description: `<ul><li><b>DHCP</b>: ${this.i18n.transform('Use the DNS servers provided by your router')}</li><li><b>${this.i18n.transform('Static')}</b>: ${this.i18n.transform('Use DNS servers you specify manually')}</li></ul>`,
variants: ISB.Variants.of({
dhcp: {
name: 'DHCP',
spec: ISB.InputSpec.of({
servers: ISB.Value.dynamicText(() => ({
name: this.i18n.transform('DHCP Servers'),
default: null,
required: true,
disabled: this.i18n.transform('Cannot edit DHCP servers'),
})),
}),
spec: ISB.InputSpec.of({}),
},
static: {
name: this.i18n.transform('Static'),
@@ -129,11 +131,21 @@ export default class SystemDnsComponent {
servers: ISB.Value.list(
ISB.List.text(
{
name: this.i18n.transform('Static Servers'),
name: this.i18n.transform('Servers'),
minLength: 1,
maxLength: 3,
},
{ placeholder: '1.1.1.1' },
{
placeholder: '1.1.1.1',
patterns: [
{
regex: `^(${ipv4.source}(:\\d{1,5})?|${ipv6.source}|\\[${ipv6.source}\\](:\\d{1,5})?)$`,
description: this.i18n.transform(
'Must be a valid IPv4 or Ipv6 address with optional port',
),
},
],
},
),
),
}),

View File

@@ -12,15 +12,20 @@ import { RouterLink } from '@angular/router'
import {
DialogService,
ErrorService,
getAllKeyboardsSorted,
getKeyboardName,
i18nKey,
i18nPipe,
i18nService,
languages,
Languages,
Keyboard,
KeyboardLayout,
Language,
LANGUAGES,
LANGUAGE_TO_CODE,
LoadingService,
} from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { TuiAnimated, TuiContext, TuiStringHandler } from '@taiga-ui/cdk'
import { TuiAnimated } from '@taiga-ui/cdk'
import {
TuiAppearance,
TuiButton,
@@ -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: `
@@ -104,20 +110,16 @@ import { SystemWipeComponent } from './wipe.component'
<tui-icon icon="@tui.languages" />
<span tuiTitle>
<strong>{{ 'Language' | i18n }}</strong>
<span tuiSubtitle [style.text-transform]="'capitalize'">
@if (language; as lang) {
{{ lang | i18n }}
} @else {
{{ i18nService.language }}
}
<span tuiSubtitle>
{{ currentLanguage?.nativeName }}
</span>
</span>
<button
tuiButtonSelect
tuiButton
[loading]="i18nService.loading()"
[ngModel]="i18nService.language"
(ngModelChange)="i18nService.setLanguage($event)"
[ngModel]="currentLanguage"
(ngModelChange)="onLanguageChange($event)"
>
{{ 'Change' | i18n }}
<tui-data-list-wrapper
@@ -125,29 +127,50 @@ import { SystemWipeComponent } from './wipe.component'
new
size="l"
[items]="languages"
[itemContent]="translation"
[itemContent]="languageContent"
/>
</button>
<ng-template #languageContent let-item>
{{ item.nativeName }}
</ng-template>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.monitor" />
<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 +237,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,
@@ -258,17 +296,60 @@ export default class SystemGeneralComponent {
readonly score = toSignal(this.patch.watch$('ui', 'snakeHighScore'))
readonly os = inject(OSService)
readonly i18nService = inject(i18nService)
readonly languages = languages
readonly translation: TuiStringHandler<TuiContext<Languages>> = ({
$implicit,
}) => {
const [head = '', ...result] = this.i18n.transform($implicit) || ''
readonly languages = LANGUAGES
return [head.toUpperCase(), ...result].join('')
get currentLanguage(): Language | undefined {
return LANGUAGES.find(lang => lang.name === this.i18nService.lang)
}
get language(): Languages | undefined {
return this.languages.find(lang => lang === this.i18nService.language)
onLanguageChange(language: Language) {
this.i18nService.setLang(language.name)
}
// 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 currentLayout = (server.keyboard?.layout as KeyboardLayout) || null
this.dialog
.openComponent<Keyboard | null>(
new PolymorpheusComponent(KeyboardSelectComponent, this.injector),
{
label: 'Select Keyboard Layout',
size: 's',
data: { keyboards, currentLayout },
},
)
.pipe(filter((keyboard): keyboard is Keyboard => keyboard !== null))
.subscribe(keyboard => {
this.saveKeyboard(keyboard)
})
}
private async saveKeyboard(keyboard: Keyboard) {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.setKeyboard({
layout: keyboard.layout,
keymap: keyboard.keymap,
model: null,
variant: null,
options: [],
})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
onUpdate() {
@@ -352,19 +433,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<Keyboard | null>(
new PolymorpheusComponent(KeyboardSelectComponent, this.injector),
{
label: 'Select Keyboard Layout',
size: 's',
data: { keyboards, currentLayout: null },
},
)
.pipe(filter((keyboard): keyboard is Keyboard => keyboard !== null))
.subscribe(keyboard => {
this.enableKioskWithKeyboard(keyboard)
})
}
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 +485,53 @@ export default class SystemGeneralComponent {
}
}
private async enableKioskWithKeyboard(keyboard: Keyboard) {
const loader = this.loader.open('Enabling').subscribe()
try {
await this.api.setKeyboard({
layout: keyboard.layout,
keymap: keyboard.keymap,
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, KeyboardLayout } 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.layout === initialLayout"
(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<
Keyboard | null,
{ keyboards: Keyboard[]; currentLayout: KeyboardLayout | null }
>
>()
protected readonly mobile = inject(TUI_IS_MOBILE)
readonly keyboards = this.context.data.keyboards
readonly initialLayout = this.context.data.currentLayout
selected =
this.keyboards.find(kb => kb.layout === this.initialLayout) ||
this.keyboards[0]!
readonly stringify = (kb: Keyboard) => kb.name
cancel() {
this.context.completeWith(null)
}
confirm() {
this.context.completeWith(this.selected)
}
}

View File

@@ -12,6 +12,7 @@ import {
DialogService,
i18nKey,
i18nPipe,
LocalizePipe,
MarkdownPipe,
SafeLinksDirective,
} from '@start9labs/shared'
@@ -138,7 +139,7 @@ import UpdatesComponent from './updates.component'
</p>
<p
safeLinks
[innerHTML]="item().releaseNotes | markdown | dompurify"
[innerHTML]="item().releaseNotes | localize | markdown | dompurify"
></p>
</tui-expand>
</td>
@@ -237,6 +238,7 @@ import UpdatesComponent from './updates.component'
TuiProgressCircle,
TuiTitle,
TuiFade,
LocalizePipe,
MarkdownPipe,
NgDompurifyPipe,
SafeLinksDirective,

View File

@@ -410,7 +410,7 @@ export namespace Mock {
docsUrl: 'https://bitcoin.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.47',
sdkVersion: '0.4.0-beta.48',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -452,7 +452,7 @@ export namespace Mock {
docsUrl: 'https://bitcoinknots.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.47',
sdkVersion: '0.4.0-beta.48',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -504,7 +504,7 @@ export namespace Mock {
docsUrl: 'https://bitcoin.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.47',
sdkVersion: '0.4.0-beta.48',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -546,7 +546,7 @@ export namespace Mock {
docsUrl: 'https://bitcoinknots.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.47',
sdkVersion: '0.4.0-beta.48',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -600,7 +600,7 @@ export namespace Mock {
docsUrl: 'https://lightning.engineering/',
releaseNotes: 'Upstream release to 0.17.5',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.47',
sdkVersion: '0.4.0-beta.48',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -655,7 +655,7 @@ export namespace Mock {
docsUrl: 'https://lightning.engineering/',
releaseNotes: 'Upstream release to 0.17.4',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.47',
sdkVersion: '0.4.0-beta.48',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -714,7 +714,7 @@ export namespace Mock {
docsUrl: 'https://bitcoin.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.47',
sdkVersion: '0.4.0-beta.48',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -756,7 +756,7 @@ export namespace Mock {
docsUrl: 'https://bitcoinknots.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.47',
sdkVersion: '0.4.0-beta.48',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -808,7 +808,7 @@ export namespace Mock {
docsUrl: 'https://lightning.engineering/',
releaseNotes: 'Upstream release and minor fixes.',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.47',
sdkVersion: '0.4.0-beta.48',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -863,7 +863,7 @@ export namespace Mock {
marketingSite: '',
releaseNotes: 'Upstream release and minor fixes.',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.47',
sdkVersion: '0.4.0-beta.48',
gitHash: 'fakehash',
icon: PROXY_ICON,
sourceVersion: null,

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

@@ -22,7 +22,6 @@ import { from, interval, map, shareReplay, startWith, Subject, tap } from 'rxjs'
import { mockPatchData } from './mock-patch'
import { AuthService } from '../auth.service'
import { T } from '@start9labs/start-sdk'
import { MarketplacePkg } from '@start9labs/marketplace'
import { WebSocketSubject } from 'rxjs/webSocket'
import { toAuthorityUrl } from 'src/app/utils/acme'
@@ -454,6 +453,36 @@ 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: params,
},
]
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,15 @@ export const mockPatchData: DataModel = {
ram: 8 * 1024 * 1024 * 1024,
devices: [],
kiosk: true,
language: 'en_US',
keyboard: {
layout: 'us',
keymap: '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> =