combine install and setup and refactor all

This commit is contained in:
Matt Hill
2026-01-13 13:11:10 -07:00
committed by Aiden McClelland
parent 645083913c
commit 42ef2bdf7e
52 changed files with 2156 additions and 2023 deletions

View File

@@ -251,10 +251,8 @@ jobs:
mkdir -p patch-db/client/dist
mkdir -p web/.angular
mkdir -p web/dist/raw/ui
mkdir -p web/dist/raw/install-wizard
mkdir -p web/dist/raw/setup-wizard
mkdir -p web/dist/static/ui
mkdir -p web/dist/static/install-wizard
mkdir -p web/dist/static/setup-wizard
PLATFORM=${{ matrix.platform }} make -t compiled-${{ env.ARCH }}.tar

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',
}

View File

@@ -6,26 +6,23 @@
"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

@@ -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,41 @@ 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':
// 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
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

@@ -16,9 +16,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'

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 { 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*</label>
<input
tuiTextfield
formControlName="hostname"
@@ -48,17 +47,17 @@ export interface CifsResponse {
/>
<tui-textfield class="input">
<label tuiLabel>Path *</label>
<label tuiLabel>Path*</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*</label>
<input
tuiTextfield
formControlName="username"
@@ -81,11 +80,14 @@ export interface CifsResponse {
tuiButton
appearance="secondary"
type="button"
[disabled]="connecting"
(click)="cancel()"
>
Cancel
</button>
<button tuiButton [disabled]="form.invalid">Verify</button>
<button tuiButton [disabled]="form.invalid" [loading]="connecting">
Connect
</button>
</footer>
</form>
`,
@@ -97,18 +99,19 @@ 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,
],
providers: [
{
@@ -122,8 +125,9 @@ export interface CifsResponse {
export class CifsComponent {
private readonly dialogs = inject(TuiDialogService)
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 +153,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 +163,25 @@ export class CifsComponent {
: null,
})
loader.unsubscribe()
const servers = Object.keys(diskInfo).map(id => ({
id,
...diskInfo[id]!,
}))
this.selectServer(diskInfo)
} catch (e) {
loader.unsubscribe()
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,
})
servers,
})
} catch (e) {
this.connecting = false
this.onFail()
}
}
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.',
'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,57 @@
import { Component, inject } from '@angular/core'
import { TuiButton } from '@taiga-ui/core'
import { TuiDialogContext } from '@taiga-ui/core'
import { injectContext } from '@taiga-ui/polymorpheus'
@Component({
standalone: true,
imports: [TuiButton],
template: `
<p>This drive contains existing StartOS data.</p>
<ul>
<li>
<strong class="g-positive">Preserve</strong>
to keep your data.
</li>
<li>
<strong class="g-negative">Overwrite</strong>
to discard
</li>
</ul>
<footer>
<button
tuiButton
appearance="flat-destructive"
(click)="context.completeWith(false)"
>
Overwrite
</button>
<button
tuiButton
class="preserve-btn"
(click)="context.completeWith(true)"
>
Preserve
</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,71 @@
import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
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],
template: `
<p>Multiple backups found. Select which one to restore.</p>
<tui-textfield [stringify]="stringify">
<label tuiLabel>Backups</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,55 @@
import { Component } from '@angular/core'
import { FormsModule } from '@angular/forms'
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],
template: `
<p>Enter the password that was used to encrypt this backup.</p>
<tui-textfield>
<label tuiLabel>Password</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
</button>
<button tuiButton [disabled]="!password" (click)="unlock()">
Unlock
</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,355 @@
import { ChangeDetectorRef, Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms'
import {
DiskInfo,
ErrorService,
LoadingService,
toGuid,
} from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import {
TuiButton,
TuiDialogService,
TuiIcon,
TuiLoader,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import {
TUI_CONFIRM,
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'
const OS_DRIVE_TOOLTIP =
'The drive where the StartOS operating system will be installed.'
const DATA_DRIVE_TOOLTIP =
'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.'
@Component({
template: `
<section tuiCardLarge="compact">
<header tuiHeader>
<h2 tuiTitle>Select Drives</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.
</p>
} @else {
<tui-textfield [stringify]="stringify">
<label tuiLabel>OS Drive</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</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' }} {{ drive.model || 'Drive' }}
</span>
<small>
{{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }}
</small>
</div>
</ng-template>
}
<footer>
@if (drives.length === 0) {
<button tuiButton appearance="secondary" (click)="refresh()">
Refresh
</button>
} @else {
<button
tuiButton
[disabled]="!selectedOsDrive || !selectedDataDrive"
(click)="continue()"
>
Continue
</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,
],
})
export default class DrivesPage {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly stateService = inject(StateService)
private readonly cdr = inject(ChangeDetectorRef)
protected readonly mobile = inject(TUI_IS_MOBILE)
readonly osDriveTooltip = OS_DRIVE_TOOLTIP
readonly dataDriveTooltip = DATA_DRIVE_TOOLTIP
drives: DiskInfo[] = []
loading = true
selectedOsDrive: DiskInfo | null = null
selectedDataDrive: DiskInfo | null = null
preserveData: boolean | null = null
readonly stringify = (drive: DiskInfo | null) =>
drive ? `${drive.vendor || 'Unknown'} ${drive.model || '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
.open<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
.open<boolean>(TUI_CONFIRM, {
label: 'Warning',
size: 's',
data: {
content: `<ul>
<li class="g-negative">Data on the OS drive may be overwritten.</li>
<li class="g-positive">your StartOS data on the data drive will be preserved.</li>
</ul>`,
yes: 'Continue',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
this.installOs(false)
})
}
private showFullWarning(sameDevice: boolean) {
const message = sameDevice
? `<p class="g-negative">Data on this drive will be overwritten.</p>`
: `<p class="g-negative">Data on both drives will be overwritten.</p>`
this.dialogs
.open<boolean>(TUI_CONFIRM, {
label: 'Warning',
size: 's',
data: {
content: message,
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
if (result.attach) {
this.stateService.setupType = 'attach'
await this.router.navigate(['/password'])
} else {
await this.router.navigate(['/home'])
}
} 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,70 @@
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 { 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' }}
<div tuiCardLarge="compact">
<header tuiHeader>
<h2 tuiTitle>Select Setup Flow</h2>
</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 tuiCell="l" (click)="startFresh()">
<tui-avatar appearance="positive" src="@tui.plus" />
<div tuiTitle>
Start Fresh
<div tuiSubtitle>Set up a brand new server</div>
</div>
</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 tuiCell="l" (click)="restore()">
<tui-avatar appearance="warning" src="@tui.archive-restore" />
<div tuiTitle>
Restore from Backup
<div tuiSubtitle>Restore StartOS data from an encrypted backup</div>
</div>
</button>
<button tuiCell="l" (click)="transfer()">
<tui-avatar appearance="info" src="@tui.hard-drive-download" />
<div tuiTitle>
Transfer
<div tuiSubtitle>
Transfer data from an existing StartOS data drive
</div>
</div>
</button>
</div>
<app-recover class="options" [disabled]="!recover" />
</div>
</section>
}
`,
styles: `
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
.logo {
width: 6rem;
margin: auto auto -2rem;
z-index: 1;
&: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%;
}
}
`,
imports: [
RouterModule,
TuiAppearance,
TuiCardLarge,
TuiButton,
TuiHeader,
TuiCell,
TuiIcon,
TuiTitle,
RecoverComponent,
TuiAvatar,
],
})
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'
try {
await this.api.getPubKey()
} catch (e: any) {
this.error = true
this.errorService.handleError(e)
} finally {
this.loading = false
this.stateService.recoverySource = undefined
await this.router.navigate(['/password'])
}
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,105 @@
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
import { StateService } from '../services/state.service'
import { Keyboard, getKeyboardsForLanguage } from '../utils/languages'
@Component({
template: `
<section tuiCardLarge="compact">
<header tuiHeader>
<h2 tuiTitle>Select Keyboard Layout</h2>
</header>
<tui-textfield
tuiChevron
[stringify]="stringify"
[tuiTextfieldCleaner]="false"
>
<label tuiLabel>Keyboard</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" (click)="continue()">
Continue
</button>
</footer>
</section>
`,
styles: `
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
}
footer {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
}
`,
imports: [
FormsModule,
TuiCardLarge,
TuiButton,
TuiTextfield,
TuiChevron,
TuiSelect,
TuiDataListWrapper,
TuiHeader,
TuiTitle,
],
})
export default class KeyboardPage {
private readonly router = inject(Router)
private readonly stateService = inject(StateService)
protected readonly mobile = inject(TUI_IS_MOBILE)
readonly keyboards = getKeyboardsForLanguage(this.stateService.language)
selected =
this.keyboards.find(k => k.code === this.stateService.keyboard) ||
this.keyboards[0]
readonly stringify = (kb: Keyboard) => kb.name
async back() {
await this.router.navigate(['/language'])
}
async continue() {
if (this.selected) {
this.stateService.keyboard = this.selected.code
await this.navigateToNextStep()
}
}
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,139 @@
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
import { StateService } from '../services/state.service'
import {
LANGUAGES,
Language,
getDefaultKeyboard,
needsKeyboardSelection,
} from '../utils/languages'
@Component({
template: `
<section tuiCardLarge="compact">
<header tuiHeader>
<h2 tuiTitle>
<span class="inline-title">
<img src="assets/img/icon.png" alt="Start9" />
Welcome to StartOS
</span>
<span tuiSubtitle>Select your language</span>
</h2>
</header>
<tui-textfield
tuiChevron
[stringify]="stringify"
[tuiTextfieldCleaner]="false"
>
<label tuiLabel>Language</label>
@if (mobile) {
<select tuiSelect [(ngModel)]="selected" [items]="languages"></select>
} @else {
<input tuiSelect [(ngModel)]="selected" />
}
@if (!mobile) {
<tui-data-list-wrapper
*tuiTextfieldDropdown
new
[items]="languages"
[itemContent]="itemContent"
/>
}
</tui-textfield>
<ng-template #itemContent let-item>
<div class="language-item">
<span>{{ item.nativeName }}</span>
@if (item.name !== item.nativeName) {
<small>{{ item.name }}</small>
}
</div>
</ng-template>
<footer>
<button tuiButton [disabled]="!selected" (click)="continue()">
Continue
</button>
</footer>
</section>
`,
styles: `
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
}
.language-item {
display: flex;
flex-direction: column;
small {
opacity: 0.7;
}
}
`,
imports: [
FormsModule,
TuiCardLarge,
TuiButton,
TuiTextfield,
TuiChevron,
TuiSelect,
TuiDataListWrapper,
TuiHeader,
TuiTitle,
],
})
export default class LanguagePage {
private readonly router = inject(Router)
private readonly stateService = inject(StateService)
protected readonly mobile = inject(TUI_IS_MOBILE)
readonly languages = LANGUAGES
selected =
LANGUAGES.find(l => l.code === this.stateService.language) || LANGUAGES[0]
readonly stringify = (lang: Language) => lang.nativeName
async continue() {
if (this.selected) {
this.stateService.language = this.selected.code
if (this.stateService.kiosk) {
// Check if we need keyboard selection
if (needsKeyboardSelection(this.selected.code)) {
await this.router.navigate(['/keyboard'])
} else {
// Auto-select the only keyboard option
this.stateService.keyboard = getDefaultKeyboard(
this.selected.code,
).code
await this.navigateToNextStep()
}
} else {
await this.navigateToNextStep()
}
}
}
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

@@ -26,18 +26,16 @@ 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</h1>
<p>{{ err }}</p>
<button tuiButton (click)="restart()">
{{ 'Restart server' }}
</button>
<button tuiButton (click)="restart()">Restart server</button>
</section>
} @else {
<app-initializing [initialSetup]="true" [progress]="progress()" />
@@ -54,7 +52,6 @@ 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);
@@ -67,9 +64,9 @@ 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 +96,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))

View File

@@ -0,0 +1,188 @@
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, i18nKey, 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' : 'Set New Password (Optional)'
}}
<span tuiSubtitle>
{{
isRequired
? 'Make it good. Write it down.'
: 'Skip to keep your existing password.'
}}
</span>
</h2>
</header>
<form [formGroup]="form" (ngSubmit)="submit()">
<tui-textfield>
<label tuiLabel>
{{ isRequired ? 'Enter Password' : 'New Password' }}
</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</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
</button>
@if (!isRequired) {
<button
tuiButton
appearance="secondary"
type="button"
(click)="skip()"
>
Skip
</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,
],
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)
// 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: '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...' as i18nKey).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,236 @@
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { ErrorService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDataList,
TuiDialogService,
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
<span tuiSubtitle>
Select the StartOS backup you want to restore
<a class="refresh" (click)="refresh()">
<tui-icon icon="@tui.rotate-cw" />
Refresh
</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
</button>
<ng-template #dropdown>
<tui-data-list>
<tui-opt-group>
<button tuiOption new (click)="openCifs()">
<tui-icon icon="@tui.folder-plus" />
Open Network Backup
</button>
</tui-opt-group>
<tui-opt-group label="Physical Backups">
@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</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,
],
})
export default class RestorePage {
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
open = false
physicalServers: StartOSDiskInfoFull[] = []
async ngOnInit() {
await this.loadDrives()
}
async refresh() {
this.loading = true
await this.loadDrives()
}
openCifs() {
this.open = false
this.dialogs
.open<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
.open<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
.open<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

@@ -7,121 +7,170 @@ import {
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 { 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">
<header tuiHeader>
<h2 tuiTitle>
<span class="inline-title">
<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>
</span>
@if (!stateService.kiosk) {
<span tuiSubtitle>
{{
stateService.setupType === 'restore'
? 'You can unplug your backup drive'
: stateService.setupType === 'transfer'
? 'You can unplug your transfer drive'
: 'http://start.local was for setup only. It will no longer work.'
}}
</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
<div tuiSubtitle>
Contains your server's permanent local address and Root CA
</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>
<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>
<!-- 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>
Remove USB Media
<div tuiSubtitle>
Remove the USB installation media from your server
</div>
</div>
@if (usbRemoved) {
<tui-icon icon="@tui.circle-check" class="g-positive" />
}
</button>
<a
tuiCardLarge
tuiSurface="floating"
target="_blank"
[attr.href]="disableLogin ? null : lanAddress"
<!-- Step: Restart Server -->
<button
tuiCell="l"
[class.disabled]="!usbRemoved"
[disabled]="!usbRemoved || rebooted || rebooting"
(click)="reboot()"
>
<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" />
<tui-avatar appearance="secondary" src="@tui.rotate-cw" />
<div tuiTitle>
Restart Server
<div tuiSubtitle>
@if (rebooting) {
Waiting for server to come back online...
} @else if (rebooted) {
Server is back online
} @else {
Restart your server to complete setup
}
</div>
</div>
@if (rebooting) {
<tui-loader />
} @else if (rebooted) {
<tui-icon icon="@tui.circle-check" class="g-positive" />
}
</button>
}
<!-- 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
<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
<div tuiSubtitle>Proceed to the StartOS login screen</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,
],
})
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)
@@ -129,32 +178,42 @@ export default class SuccessPage implements AfterViewInit {
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 +221,56 @@ 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()
if (!this.stateService.kiosk) {
this.lanAddress = `https://${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(
'Server did not come back online. Please check your server and try accessing it manually.',
)
}
}

View File

@@ -1,97 +1,167 @@
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import {
DiskInfo,
DriveComponent,
ErrorService,
toGuid,
} from '@start9labs/shared'
import { DiskInfo, ErrorService, toGuid } from '@start9labs/shared'
import {
TuiButton,
TuiDataList,
TuiDialogOptions,
TuiDialogService,
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
<span tuiSubtitle>
Select the drive containing your existing StartOS data
<a class="refresh" (click)="refresh()">
<tui-icon icon="@tui.rotate-cw" />
Refresh
</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
</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</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,
],
})
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 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
const WARNING_OPTIONS: Partial<TuiDialogOptions<TuiConfirmData>> = {
label: 'Warning',
size: 's',
data: {
content: `After transferring data from this drive, <strong>do not</strong>
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',
},
}
this.dialogs
.open(TUI_CONFIRM, WARNING_OPTIONS)
.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,56 @@
import * as jose from 'node-jose'
import {
DiskInfo,
DiskListResponse,
FollowLogsRes,
PartitionInfo,
StartOSDiskInfo,
} from '@start9labs/shared'
import { DiskInfo, FollowLogsRes, 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
// 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
// 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,6 +1,6 @@
import { Inject, Injectable, DOCUMENT } from '@angular/core'
import {
DiskListResponse,
DiskInfo,
encodeBase64,
FollowLogsRes,
HttpService,
@@ -14,6 +14,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 +45,40 @@ 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 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 +86,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 +126,22 @@ export class LiveApiService extends ApiService {
}
}
async exit(): Promise<void> {
async exit() {
await this.rpcRequest<void>({
method: 'setup.exit',
params: {},
})
}
async restart(): Promise<void> {
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 +151,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,6 +1,6 @@
import { Injectable } from '@angular/core'
import {
DiskListResponse,
DiskInfo,
encodeBase64,
FollowLogsRes,
pauseFor,
@@ -8,104 +8,24 @@ import {
} 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 +37,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 +52,39 @@ 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:
if (this.statusIndex === 1) {
// return { status: 'needs-install' }
return { status: 'incomplete', attach: false, guid: 'mock-data-guid' }
}
if (this.statusIndex > 3) {
return { status: 'complete' }
}
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
}
}
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 +93,18 @@ 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 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 +120,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 +150,109 @@ 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 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 +274,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,72 @@ 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 = '' // only used if kiosk
// 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,
startOsPassword: password ? await this.api.encrypt(password) : null,
language: this.language,
kiosk: this.kiosk ? { keyboard: this.keyboard } : null,
})
}
async setupEmbassy(
storageLogicalname: string,
password: string,
): Promise<void> {
await this.api.execute({
startOsLogicalname: storageLogicalname,
startOsPassword: await this.api.encrypt(password),
recoverySource: this.recoverySource
? this.recoverySource.type === 'migrate'
? this.recoverySource
: {
...this.recoverySource,
/**
* 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),
}
: null,
kiosk: this.kiosk,
}
}
await this.api.execute({
startOsLogicalname: this.dataDriveGuid,
startOsPassword: password ? await this.api.encrypt(password) : null,
language: this.language,
kiosk: this.kiosk ? { keyboard: this.keyboard } : null,
recoverySource,
})
}
/**
* 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,88 @@
import { DiskInfo, 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' }
| { status: 'incomplete'; guid: string; attach: boolean }
| { 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 {
startOsPassword: T.EncryptedWire | null
guid: string // data drive
language: string
kiosk: { keyboard: string } | null
}
// === Execute ===
export interface SetupExecuteParams {
startOsLogicalname: string
startOsPassword: T.EncryptedWire | null // null = keep existing password (for restore/transfer)
language: string
kiosk: { keyboard: string } | null
recoverySource:
| {
type: 'migrate'
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

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

View File

@@ -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;

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

@@ -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'

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

@@ -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'