chore: refactor install and setup wizards (#2561)

* chore: refactor install and setup wizards

* chore: return tui-root
This commit is contained in:
Alex Inkin
2024-02-22 17:58:01 +04:00
committed by GitHub
parent 69d5f521a5
commit 7b41b295b7
109 changed files with 1863 additions and 3538 deletions

View File

@@ -184,12 +184,7 @@
}
],
"styles": [
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less",
"projects/shared/styles/taiga.scss",
"projects/shared/styles/variables.scss",
"projects/shared/styles/global.scss",
"projects/shared/styles/shared.scss",
"projects/install-wizard/src/styles.scss"
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less"
],
"scripts": []
},
@@ -322,10 +317,6 @@
],
"styles": [
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less",
"projects/shared/styles/taiga.scss",
"projects/shared/styles/variables.scss",
"projects/shared/styles/global.scss",
"projects/shared/styles/shared.scss",
"projects/setup-wizard/src/styles.scss"
],
"scripts": []

26
web/package-lock.json generated
View File

@@ -24,15 +24,15 @@
"@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5",
"@start9labs/start-sdk": "0.4.0-rev0.lib0.rc8.beta2",
"@taiga-ui/addon-charts": "^3.65.0",
"@taiga-ui/addon-commerce": "^3.65.0",
"@taiga-ui/addon-mobile": "^3.65.0",
"@taiga-ui/cdk": "^3.65.0",
"@taiga-ui/core": "^3.65.0",
"@taiga-ui/experimental": "^3.65.0",
"@taiga-ui/icons": "^3.65.0",
"@taiga-ui/kit": "^3.65.0",
"@taiga-ui/styles": "^3.65.0",
"@taiga-ui/addon-charts": "3.65.0",
"@taiga-ui/addon-commerce": "3.65.0",
"@taiga-ui/addon-mobile": "3.65.0",
"@taiga-ui/cdk": "3.65.0",
"@taiga-ui/core": "3.65.0",
"@taiga-ui/experimental": "3.65.0",
"@taiga-ui/icons": "3.65.0",
"@taiga-ui/kit": "3.65.0",
"@taiga-ui/styles": "3.65.0",
"@tinkoff/ng-dompurify": "4.0.0",
"@tinkoff/ng-event-plugins": "3.1.0",
"ansi-to-html": "^0.7.2",
@@ -83,7 +83,7 @@
"ng-packagr": "^17.0.2",
"node-html-parser": "^5.3.3",
"postcss": "^8.4.21",
"prettier": "^3.1.1",
"prettier": "^3.2.5",
"raw-loader": "^4.0.2",
"ts-node": "^10.7.0",
"tslint": "^6.1.3",
@@ -14107,9 +14107,9 @@
}
},
"node_modules/prettier": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz",
"integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==",
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"

View File

@@ -105,7 +105,7 @@
"ng-packagr": "^17.0.2",
"node-html-parser": "^5.3.3",
"postcss": "^8.4.21",
"prettier": "^3.1.1",
"prettier": "^3.2.5",
"raw-loader": "^4.0.2",
"ts-node": "^10.7.0",
"tslint": "^6.1.3",

View File

@@ -1,22 +0,0 @@
import { NgModule } from '@angular/core'
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
const routes: Routes = [
{
path: '',
loadChildren: () =>
import('./pages/home/home.module').then(m => m.HomePageModule),
},
]
@NgModule({
imports: [
RouterModule.forRoot(routes, {
scrollPositionRestoration: 'enabled',
preloadingStrategy: PreloadAllModules,
useHash: true,
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}

View File

@@ -1,6 +1,58 @@
<tui-theme-night></tui-theme-night>
<tui-root tuiMode="onDark">
<ion-app>
<ion-router-outlet></ion-router-outlet>
</ion-app>
<tui-root>
<main>
<img class="logo" src="assets/img/icon.png" alt="Start9" />
<section tuiCardLarge tuiSurface="elevated" class="card">
<header class="header">
@if (selected) {
<button
tuiIconButton
appearance="flat"
size="m"
class="back"
iconLeft="tuiIconChevronLeft"
[style.border-radius.rem]="10"
(click)="selected = null"
>
Back
</button>
}
<h1>{{ selected ? 'Install Type' : 'Select Disk' }}</h1>
<div [style.color]="'var(--tui-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="tuiIconLifeBuoyLarge" />
<span tuiTitle>
<strong [style.color]="'var(--tui-positive)'">
Re-Install StartOS
</strong>
<span tuiSubtitle>Will preserve existing StartOS data</span>
</span>
</button>
}
<button tuiCell [disabled]="!selected" (click)="warn()">
<tui-icon icon="tuiIconDownload" />
<span tuiTitle>
@if (guid) {
<span [style.color]="'var(--tui-negative)'">Factory Reset</span>
} @else {
<span [style.color]="'var(--tui-positive)'">
Install StartOS
</span>
}
<span tuiSubtitle>Will delete existing data on disk</span>
</span>
</button>
</div>
</div>
</section>
</main>
</tui-root>

View File

@@ -1,8 +1,63 @@
:host {
display: block;
height: 100%;
}
@import '@taiga-ui/core/styles/taiga-ui-local';
::ng-deep html,
::ng-deep body,
tui-root {
height: 100%;
margin: 0;
color: var(--tui-text-01);
}
main {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: var(--tui-base-08);
}
.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;
}
.back {
position: absolute;
top: 1rem;
}
.pages {
display: flex;
align-items: center;
overflow: hidden;
}
.options {
@include transition(margin);
min-width: 100%;
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1rem;
box-sizing: border-box;
&_selected {
margin-left: -100%;
}
}

View File

@@ -1,4 +1,10 @@
import { Component } from '@angular/core'
import { Component, inject } from '@angular/core'
import { DiskInfo, LoadingService, toGuid } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { TUI_PROMPT } from '@taiga-ui/kit'
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',
@@ -6,5 +12,65 @@ import { Component } from '@angular/core'
styleUrls: ['app.component.scss'],
})
export class AppComponent {
constructor() {}
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...').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_PROMPT, toWarning(this.selected))
.pipe(filter(Boolean))
.subscribe(() => {
this.install(true)
})
}
private async reboot() {
this.dialogs
.open(
'Remove the USB stick and reboot your device to begin using your new Start9 server',
SUCCESS,
)
.subscribe({
complete: async () => {
const loader = this.loader.open('').subscribe()
try {
await this.api.reboot()
this.dialogs
.open(
'Please wait for StartOS to restart, then refresh this page',
{ label: 'Rebooting', size: 's' },
)
.subscribe()
} catch (e: any) {
this.error = e.message
} finally {
loader.unsubscribe()
}
},
})
}
}

View File

@@ -1,24 +1,26 @@
import { HttpClientModule } from '@angular/common/http'
import { NgModule } from '@angular/core'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { RouteReuseStrategy } from '@angular/router'
import { IonicModule, IonicRouteStrategy } from '@ionic/angular'
import {
TuiDialogModule,
TuiModeModule,
TuiRootModule,
TuiThemeNightModule,
} from '@taiga-ui/core'
import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module'
import { HttpClientModule } from '@angular/common/http'
import { ApiService } from './services/api/api.service'
import { MockApiService } from './services/api/mock-api.service'
import { LiveApiService } from './services/api/live-api.service'
import {
DriveComponent,
LoadingModule,
RELATIVE_URL,
UnitConversionPipesModule,
WorkspaceConfig,
} from '@start9labs/shared'
import { TuiDialogModule, TuiRootModule } from '@taiga-ui/core'
import {
TuiButtonModule,
TuiCardModule,
TuiCellModule,
TuiIconModule,
TuiSurfaceModule,
TuiTitleModule,
} from '@taiga-ui/experimental'
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,
@@ -30,18 +32,19 @@ const {
imports: [
HttpClientModule,
BrowserAnimationsModule,
IonicModule.forRoot({
mode: 'md',
}),
AppRoutingModule,
TuiRootModule,
TuiDialogModule,
LoadingModule,
TuiModeModule,
TuiThemeNightModule,
DriveComponent,
TuiButtonModule,
TuiCardModule,
TuiCellModule,
TuiIconModule,
TuiSurfaceModule,
TuiTitleModule,
UnitConversionPipesModule,
],
providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,

View File

@@ -0,0 +1,27 @@
import { DiskInfo } from '@start9labs/shared'
import { TuiDialogOptions } from '@taiga-ui/core'
import { TuiPromptData } from '@taiga-ui/kit'
export const SUCCESS: Partial<TuiDialogOptions<any>> = {
label: 'Install Success',
closeable: false,
dismissible: false,
size: 's',
data: { button: 'Reboot' },
}
export function toWarning(
disk: DiskInfo | null,
): Partial<TuiDialogOptions<TuiPromptData>> {
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,32 +0,0 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { HomePage } from './home.page'
import { SwiperModule } from 'swiper/angular'
import {
UnitConversionPipesModule,
GuidPipePipesModule,
} from '@start9labs/shared'
const routes: Routes = [
{
path: '',
component: HomePage,
},
]
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
RouterModule.forChild(routes),
SwiperModule,
UnitConversionPipesModule,
GuidPipePipesModule,
],
declarations: [HomePage],
})
export class HomePageModule {}

View File

@@ -1,107 +0,0 @@
<ion-content>
<ion-grid>
<ion-row>
<ion-col class="ion-text-center">
<div style="padding: 64px 0 32px 0">
<img src="assets/img/icon.png" style="max-width: 100px" />
</div>
<ion-card color="dark">
<ion-card-header>
<ion-button
*ngIf="swiper?.activeIndex === 1"
class="back-button"
fill="clear"
color="light"
(click)="previous()"
>
<ion-icon slot="icon-only" name="arrow-back"></ion-icon>
</ion-button>
<ion-card-title>
{{ !swiper || swiper.activeIndex === 0 ? 'Select Disk' : 'Install
Type' }}
</ion-card-title>
<ion-card-subtitle *ngIf="error">
<ion-text color="danger">{{ error }}</ion-text>
</ion-card-subtitle>
</ion-card-header>
<ion-card-content class="ion-margin-bottom">
<swiper (swiper)="setSwiperInstance($event)">
<!-- SLIDE 1 -->
<ng-template swiperSlide>
<ion-item
*ngFor="let disk of disks"
button
(click)="next(disk)"
>
<ion-icon
slot="start"
name="save-outline"
size="large"
color="dark"
></ion-icon>
<ion-label class="ion-text-wrap">
<h1>
{{ disk.vendor || 'Unknown Vendor' }} - {{ disk.model ||
'Unknown Model' }}
</h1>
<h2>
{{ disk.logicalname }} - {{ disk.capacity | convertBytes
}}
</h2>
</ion-label>
</ion-item>
</ng-template>
<!-- SLIDE 2 -->
<ng-template swiperSlide>
<ng-container *ngIf="selectedDisk">
<!-- re-install -->
<ion-item
*ngIf="selectedDisk | guid"
button
(click)="tryInstall(false)"
>
<ion-icon
color="dark"
slot="start"
size="large"
name="medkit-outline"
></ion-icon>
<ion-label>
<h1>
<ion-text color="success">Re-Install StartOS</ion-text>
</h1>
<h2>Will preserve existing StartOS data</h2>
</ion-label>
</ion-item>
<!-- fresh install -->
<ion-item button lines="none" (click)="tryInstall(true)">
<ion-icon
color="dark"
slot="start"
size="large"
name="download-outline"
></ion-icon>
<ion-label>
<h1>
<ion-text
[color]="(selectedDisk | guid) ? 'danger' : 'success'"
>
{{ (selectedDisk | guid) ? 'Factory Reset' : 'Install
StartOS' }}
</ion-text>
</h1>
<h2>Will delete existing data on disk</h2>
</ion-label>
</ion-item>
</ng-container>
</ng-template>
</swiper>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -1,28 +0,0 @@
/** Ionic CSS Variables overrides **/
:root {
--ion-font-family: 'Benton Sans', sans-serif;
}
ion-content {
--background: var(--ion-color-medium);
}
ion-grid {
padding-top: 32px;
height: 100%;
max-width: 640px;
}
.back-button {
position: absolute;
left: 16px;
top: 24px;
z-index: 1000000;
}
ion-card-title {
margin: 16px 0;
font-family: 'Montserrat';
font-size: x-large;
--color: var(--ion-color-light);
}

View File

@@ -1,137 +0,0 @@
import { Component } from '@angular/core'
import { IonicSlides } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/api.service'
import SwiperCore, { Swiper } from 'swiper'
import { DiskInfo, LoadingService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { filter } from 'rxjs'
SwiperCore.use([IonicSlides])
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage {
swiper?: Swiper
disks: DiskInfo[] = []
selectedDisk?: DiskInfo
error = ''
constructor(
private readonly loader: LoadingService,
private readonly api: ApiService,
private readonly dialogs: TuiDialogService,
) {}
async ngOnInit() {
this.disks = await this.api.getDisks()
}
async ionViewDidEnter() {
if (this.swiper) {
this.swiper.allowTouchMove = false
}
}
setSwiperInstance(swiper: any) {
this.swiper = swiper
}
next(disk: DiskInfo) {
this.selectedDisk = disk
this.swiper?.slideNext(500)
}
previous() {
this.swiper?.slidePrev(500)
}
async tryInstall(overwrite: boolean) {
if (overwrite) {
return this.presentAlertDanger()
}
this.install(false)
}
private async install(overwrite: boolean) {
const loader = this.loader.open('Installing StartOS...').subscribe()
try {
await this.api.install({
logicalname: this.selectedDisk!.logicalname,
overwrite,
})
this.presentAlertReboot()
} catch (e: any) {
this.error = e.message
} finally {
loader.unsubscribe()
}
}
private presentAlertDanger() {
const { vendor, model } = this.selectedDisk!
this.dialogs
.open(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: {
content: `This action will COMPLETELY erase the disk ${
vendor || 'Unknown Vendor'
} - ${model || 'Unknown Model'} and install StartOS in its place`,
yes: 'Continue',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
this.install(true)
})
}
private async presentAlertReboot() {
this.dialogs
.open(
'Remove the USB stick and reboot your device to begin using your new Start9 server',
{
label: 'Install Success',
closeable: false,
dismissible: false,
size: 's',
data: { button: 'Reboot' },
},
)
.subscribe({
complete: () => {
this.reboot()
},
})
}
private async reboot() {
const loader = this.loader.open('').subscribe()
try {
await this.api.reboot()
this.presentAlertComplete()
} catch (e: any) {
this.error = e.message
} finally {
loader.unsubscribe()
}
}
private presentAlertComplete() {
this.dialogs
.open('Please wait for StartOS to restart, then refresh this page', {
label: 'Rebooting',
size: 's',
})
.subscribe()
}
}

View File

@@ -1,59 +0,0 @@
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: normal;
src: url('/assets/fonts/Montserrat/Montserrat-Regular.ttf');
}
/** Ionic CSS Variables overrides **/
:root {
--ion-font-family: 'Montserrat', sans-serif;
--ion-color-primary: #0075e1;
--ion-color-medium: #989aa2;
--ion-color-medium-rgb: 152,154,162;
--ion-color-medium-contrast: #000000;
--ion-color-medium-contrast-rgb: 0,0,0;
--ion-color-medium-shade: #86888f;
--ion-color-medium-tint: #a2a4ab;
--ion-color-light: #222428;
--ion-color-light-rgb: 34,36,40;
--ion-color-light-contrast: #ffffff;
--ion-color-light-contrast-rgb: 255,255,255;
--ion-color-light-shade: #1e2023;
--ion-color-light-tint: #383a3e;
--ion-item-background: #2b2b2b;
--ion-toolbar-background: #2b2b2b;
--ion-card-background: #2b2b2b;
--ion-background-color: #282828;
--ion-background-color-rgb: 30,30,30;
--ion-text-color: var(--ion-color-dark);
--ion-text-color-rgb: var(--ion-color-dark-rgb);
}
.loader {
--spinner-color: var(--ion-color-warning) !important;
z-index: 40000 !important;
}
.alert-danger-message {
.alert-title {
color: var(--ion-color-danger);
}
}
.alert-success-message {
.alert-title {
color: var(--ion-color-success);
}
}
ion-alert {
.alert-button {
color: var(--ion-color-dark) !important;
}
}

View File

@@ -1,11 +1,10 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { ResponsiveColDirective } from "@start9labs/shared";
import { SearchComponent } from "./search.component";
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { SearchComponent } from './search.component'
@NgModule({
imports: [FormsModule, CommonModule, ResponsiveColDirective],
imports: [FormsModule, CommonModule],
declarations: [SearchComponent],
exports: [SearchComponent],
})

View File

@@ -1,19 +1,17 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { AdditionalComponent } from "./additional.component";
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { AdditionalComponent } from './additional.component'
import {
TuiRadioListModule,
TuiStringifyContentPipeModule,
} from "@taiga-ui/kit";
import { FormsModule } from "@angular/forms";
import { TuiButtonModule, TuiLabelModule } from "@taiga-ui/core";
import { AdditionalLinkModule } from "./additional-link/additional-link.component.module";
import { ResponsiveColDirective } from "@start9labs/shared";
} from '@taiga-ui/kit'
import { FormsModule } from '@angular/forms'
import { TuiButtonModule, TuiLabelModule } from '@taiga-ui/core'
import { AdditionalLinkModule } from './additional-link/additional-link.component.module'
@NgModule({
imports: [
CommonModule,
ResponsiveColDirective,
TuiRadioListModule,
FormsModule,
TuiStringifyContentPipeModule,

View File

@@ -1,17 +1,11 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router'
import { EmverPipesModule, ResponsiveColDirective } from '@start9labs/shared'
import { EmverPipesModule } from '@start9labs/shared'
import { DependenciesComponent } from './dependencies.component'
import { TuiAvatarModule } from '@taiga-ui/experimental'
@NgModule({
imports: [
CommonModule,
RouterModule,
ResponsiveColDirective,
TuiAvatarModule,
EmverPipesModule,
],
imports: [CommonModule, RouterModule, TuiAvatarModule, EmverPipesModule],
declarations: [DependenciesComponent],
exports: [DependenciesComponent],
})

View File

@@ -1,56 +0,0 @@
import { NgModule } from '@angular/core'
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
const routes: Routes = [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{
path: 'home',
loadChildren: () =>
import('./pages/home/home.module').then(m => m.HomePageModule),
},
{
path: 'attach',
loadChildren: () =>
import('./pages/attach/attach.module').then(m => m.AttachPageModule),
},
{
path: 'recover',
loadChildren: () =>
import('./pages/recover/recover.module').then(m => m.RecoverPageModule),
},
{
path: 'transfer',
loadChildren: () =>
import('./pages/transfer/transfer.module').then(
m => m.TransferPageModule,
),
},
{
path: 'storage',
loadChildren: () =>
import('./pages/embassy/embassy.module').then(m => m.EmbassyPageModule),
},
{
path: 'loading',
loadChildren: () =>
import('./pages/loading/loading.module').then(m => m.LoadingPageModule),
},
{
path: 'success',
loadChildren: () =>
import('./pages/success/success.module').then(m => m.SuccessPageModule),
},
]
@NgModule({
imports: [
RouterModule.forRoot(routes, {
scrollPositionRestoration: 'enabled',
preloadingStrategy: PreloadAllModules,
useHash: true,
initialNavigation: 'disabled',
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}

View File

@@ -1,6 +0,0 @@
<tui-theme-night></tui-theme-night>
<tui-root tuiMode="onDark">
<ion-app>
<ion-router-outlet></ion-router-outlet>
</ion-app>
</tui-root>

View File

@@ -1,8 +0,0 @@
:host {
display: block;
height: 100%;
}
tui-root {
height: 100%;
}

View File

@@ -1,30 +1,31 @@
import { Component } from '@angular/core'
import { NavController } from '@ionic/angular'
import { ApiService } from './services/api/api.service'
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { ErrorService } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api.service'
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
template: `
<tui-theme-night></tui-theme-night>
<tui-root tuiMode="onDark"><router-outlet /></tui-root>
`,
})
export class AppComponent {
constructor(
private readonly apiService: ApiService,
private readonly errorService: ErrorService,
private readonly navCtrl: NavController,
) {}
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly router = inject(Router)
async ngOnInit() {
try {
const inProgress = await this.apiService.getSetupStatus()
const inProgress = await this.api.getSetupStatus()
let route = 'home'
let route = '/home'
if (inProgress) {
route = inProgress.complete ? '/success' : '/loading'
route = inProgress.complete ? 'success' : 'loading'
}
await this.navCtrl.navigateForward(route)
await this.router.navigate([route])
} catch (e: any) {
this.errorService.handleError(e)
}

View File

@@ -1,30 +1,7 @@
import { HttpClientModule } from '@angular/common/http'
import { NgModule } from '@angular/core'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { RouteReuseStrategy } from '@angular/router'
import { HttpClientModule } from '@angular/common/http'
import {
TuiAlertModule,
tuiButtonOptionsProvider,
TuiDialogModule,
TuiModeModule,
TuiRootModule,
TuiThemeNightModule,
} from '@taiga-ui/core'
import { ApiService } from './services/api/api.service'
import { MockApiService } from './services/api/mock-api.service'
import { LiveApiService } from './services/api/live-api.service'
import {
IonicModule,
IonicRouteStrategy,
iosTransitionAnimation,
} from '@ionic/angular'
import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module'
import { SuccessPageModule } from './pages/success/success.module'
import { HomePageModule } from './pages/home/home.module'
import { LoadingPageModule } from './pages/loading/loading.module'
import { RecoverPageModule } from './pages/recover/recover.module'
import { TransferPageModule } from './pages/transfer/transfer.module'
import { PreloadAllModules, RouterModule } from '@angular/router'
import {
LoadingModule,
provideSetupLogsService,
@@ -32,6 +9,19 @@ import {
RELATIVE_URL,
WorkspaceConfig,
} from '@start9labs/shared'
import {
TuiAlertModule,
TuiDialogModule,
TuiModeModule,
TuiRootModule,
TuiThemeNightModule,
} from '@taiga-ui/core'
import { tuiButtonOptionsProvider } from '@taiga-ui/experimental'
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'
import { ROUTES } from './app.routes'
const {
useMocks,
@@ -42,21 +32,15 @@ const {
declarations: [AppComponent],
imports: [
BrowserAnimationsModule,
IonicModule.forRoot({
mode: 'md',
navAnimation: iosTransitionAnimation,
}),
AppRoutingModule,
HttpClientModule,
SuccessPageModule,
HomePageModule,
LoadingPageModule,
RecoverPageModule,
TransferPageModule,
RouterModule.forRoot(ROUTES, {
preloadingStrategy: PreloadAllModules,
initialNavigation: 'disabled',
}),
LoadingModule,
TuiRootModule,
TuiDialogModule,
TuiAlertModule,
LoadingModule,
TuiModeModule,
TuiThemeNightModule,
],
@@ -64,7 +48,6 @@ const {
provideSetupService(ApiService),
provideSetupLogsService(ApiService),
tuiButtonOptionsProvider({ size: 'm' }),
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,

View File

@@ -0,0 +1,33 @@
import { Routes } from '@angular/router'
export const ROUTES: Routes = [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{
path: 'home',
loadComponent: () => import('src/app/pages/home.page'),
},
{
path: 'attach',
loadComponent: () => import('src/app/pages/attach.page'),
},
{
path: 'recover',
loadComponent: () => import('src/app/pages/recover.page'),
},
{
path: 'transfer',
loadComponent: () => import('src/app/pages/transfer.page'),
},
{
path: 'storage',
loadComponent: () => import('src/app/pages/storage.page'),
},
{
path: 'loading',
loadComponent: () => import('src/app/pages/loading.page'),
},
{
path: 'success',
loadComponent: () => import('src/app/pages/success.page'),
},
]

View File

@@ -0,0 +1,195 @@
import { CommonModule } from '@angular/common'
import { Component, inject, Inject } from '@angular/core'
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { LoadingService, StartOSDiskInfo } from '@start9labs/shared'
import {
TuiButtonModule,
TuiDialogContext,
TuiDialogService,
TuiErrorModule,
} from '@taiga-ui/core'
import {
TUI_VALIDATION_ERRORS,
TuiFieldErrorPipeModule,
TuiInputModule,
TuiInputPasswordModule,
} from '@taiga-ui/kit'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { PASSWORD } from 'src/app/components/password.component'
import {
ApiService,
CifsBackupTarget,
CifsRecoverySource,
} from 'src/app/services/api.service'
interface Context {
cifs: CifsRecoverySource
recoveryPassword: string
}
@Component({
standalone: true,
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<tui-input formControlName="hostname">
Hostname
<input
tuiTextfield
placeholder="'My Computer' OR 'my-computer.local'"
/>
</tui-input>
<tui-error
formControlName="hostname"
[error]="['required'] | tuiFieldError | async"
></tui-error>
<tui-input formControlName="path" class="input">
Path
<input tuiTextfield placeholder="/Desktop/my-folder'" />
</tui-input>
<tui-error
formControlName="path"
[error]="[] | tuiFieldError | async"
></tui-error>
<tui-input formControlName="username" class="input">
Username
<input tuiTextfield placeholder="Enter username" />
</tui-input>
<tui-error
formControlName="username"
[error]="[] | tuiFieldError | async"
></tui-error>
<tui-input-password formControlName="password" class="input">
Password
</tui-input-password>
<footer>
<button
tuiButton
appearance="secondary"
type="button"
(click)="cancel()"
>
Cancel
</button>
<button tuiButton [disabled]="form.invalid">Verify</button>
</footer>
</form>
`,
styles: [
'.input { margin-top: 1rem }',
'footer { display: flex; gap: 1rem; margin-top: 1rem }',
],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
TuiButtonModule,
TuiInputModule,
TuiInputPasswordModule,
TuiErrorModule,
TuiFieldErrorPipeModule,
],
providers: [
{
provide: TUI_VALIDATION_ERRORS,
useValue: {
required: 'This field is required',
},
},
],
})
export class CifsComponent {
private readonly dialogs = inject(TuiDialogService)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly context =
inject<TuiDialogContext<Context>>(POLYMORPHEUS_CONTEXT)
readonly form = new FormGroup({
hostname: new FormControl('', {
validators: [
Validators.required,
Validators.pattern(/^[a-zA-Z0-9._-]+( [a-zA-Z0-9]+)*$/),
],
nonNullable: true,
}),
path: new FormControl('', {
validators: [Validators.required],
nonNullable: true,
}),
username: new FormControl('', {
validators: [Validators.required],
nonNullable: true,
}),
password: new FormControl(),
})
cancel() {
this.context.$implicit.complete()
}
async submit(): Promise<void> {
const loader = this.loader
.open('Connecting to shared folder...')
.subscribe()
try {
const diskInfo = await this.api.verifyCifs({
...this.form.getRawValue(),
type: 'cifs',
password: this.form.value.password
? await this.api.encrypt(String(this.form.value.password))
: null,
})
loader.unsubscribe()
this.presentModalPassword(diskInfo)
} catch (e) {
loader.unsubscribe()
this.presentAlertFailed()
}
}
private presentModalPassword(diskInfo: StartOSDiskInfo) {
const target: CifsBackupTarget = {
...this.form.getRawValue(),
mountable: true,
'embassy-os': diskInfo,
}
this.dialogs
.open<string>(PASSWORD, {
label: 'Unlock Drive',
size: 's',
data: { target },
})
.subscribe(recoveryPassword => {
this.context.completeWith({
cifs: { ...this.form.getRawValue(), type: 'cifs' },
recoveryPassword,
})
})
}
private presentAlertFailed() {
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.',
{
label: 'Connection Failed',
size: 's',
},
)
.subscribe()
}
}

View File

@@ -0,0 +1,145 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
@Component({
standalone: true,
selector: 'app-documentation',
template: `
<!doctype html>
<html lang="en">
<head>
<title>StartOS Address Info</title>
</head>
<body>
<div
style="
font-family: Montserrat, sans-serif;
color: #333333;
display: flex;
flex-direction: column;
margin: auto;
width: clamp(900px, 35vw, 600px);
"
>
<h1
style="
font-variant-caps: all-small-caps;
text-align: center;
padding: 1rem;
"
>
StartOS Address Info
</h1>
<section
style="
padding: 1rem 3rem 2rem 3rem;
margin-bottom: 24px;
border: solid #c4c4c5 3px;
border-radius: 20px;
"
>
<div>
<h3 style="color: #f8546a; font-weight: bold">Important!</h3>
<p>
Download your server's Root CA and
<a
href="https://docs.start9.com/0.3.5.x/user-manual/connecting-lan"
target="_blank"
rel="noreferrer"
style="color: #6866cc; font-weight: bold; text-decoration: none"
>
follow the instructions
</a>
to establish a secure connection with your server.
</p>
</div>
<div style="text-align: center">
<a
id="cert"
[download]="crtName"
style="
display: inline-block;
padding: 1em 1.2em;
box-sizing: border-box;
font-size: 1rem;
text-decoration: none;
text-align: center;
border-radius: clamp(2rem, 3rem, 4rem);
cursor: pointer;
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px,
rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
background: #6866cc;
color: #f4f4f5;
"
>
Download certificate
</a>
</div>
</section>
<section
style="
padding: 1rem 3rem 2rem 3rem;
border: solid #c4c4c5 3px;
border-radius: 20px;
margin-bottom: 24px;
"
>
<h2 style="font-variant-caps: all-small-caps">
Access from home (LAN)
</h2>
<p>
Visit the address below when you are connected to the same WiFi or
Local Area Network (LAN) as your server.
</p>
<p
style="
padding: 16px;
font-weight: bold;
font-size: 1.1rem;
overflow: auto;
"
>
<code id="lan-addr"></code>
</p>
<h2 style="font-variant-caps: all-small-caps">
Access on the go (Tor)
</h2>
<p>Visit the address below when you are away from home.</p>
<p>
<span style="font-weight: bold">Note:</span>
This address will only work from a Tor-enabled browser.
<a
href="https://docs.start9.com/0.3.5.x/user-manual/connecting-tor"
target="_blank"
rel="noreferrer"
style="color: #6866cc; font-weight: bold; text-decoration: none"
>
Follow the instructions
</a>
to get setup.
</p>
<p
style="
padding: 16px;
font-weight: bold;
font-size: 1.1rem;
overflow: auto;
"
>
<code id="tor-addr"></code>
</p>
</section>
</div>
</body>
</html>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DocumentationComponent {
@Input({ required: true }) lanAddress!: string
get crtName(): string {
return `${new URL(this.lanAddress).hostname}.crt`
}
}

View File

@@ -0,0 +1,79 @@
import {
Component,
Directive,
ElementRef,
inject,
NgZone,
OnInit,
} from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
// a higher fade factor will make the characters fade quicker
const FADE_FACTOR = 0.07
@Component({
standalone: true,
selector: 'canvas[matrix]',
template: 'Your browser does not support the canvas element.',
styles: ':host { position: fixed; }',
})
export class MatrixComponent implements OnInit {
private readonly ngZone = inject(NgZone)
private readonly window = inject(WINDOW)
private readonly el: HTMLCanvasElement = inject(ElementRef).nativeElement
private readonly ctx = this.el.getContext('2d')!
private tileSize = 16
private columns: any[] = []
private maxStackHeight = 0
ngOnInit() {
this.ngZone.runOutsideAngular(() => {
this.setupMatrixGrid()
this.tick()
})
}
private setupMatrixGrid() {
this.el.width = Math.max(this.window.innerWidth, 1920)
this.el.height = Math.max(this.window.innerHeight, 1080)
this.maxStackHeight = Math.ceil(this.ctx.canvas.height / this.tileSize)
// divide the canvas into columns
for (let i = 0; i < this.ctx.canvas.width / this.tileSize; ++i) {
const column = {} as any
// save the x position of the column
column.x = i * this.tileSize
// create a random stack height for the column
column.stackHeight = 10 + Math.random() * this.maxStackHeight
// add a counter to count the stack height
column.stackCounter = 0
// add the column to the list
this.columns.push(column)
}
}
private draw() {
// draw a semi transparent black rectangle on top of the scene to slowly fade older characters
this.ctx.fillStyle = `rgba(0, 0, 0, ${FADE_FACTOR})`
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
// pick a font slightly smaller than the tile size
this.ctx.font = `${this.tileSize - 2}px monospace`
this.ctx.fillStyle = '#ff4961'
for (let i = 0; i < this.columns.length; ++i) {
// pick a random ascii character (change the 94 to a higher number to include more characters)
const { x, stackCounter } = this.columns[i]
const char = String.fromCharCode(33 + Math.floor(Math.random() * 94))
this.ctx.fillText(char, x, stackCounter * this.tileSize + this.tileSize)
// if the stack is at its height limit, pick a new random height and reset the counter
if (++this.columns[i].stackCounter >= this.columns[i].stackHeight) {
this.columns[i].stackHeight = 10 + Math.random() * this.maxStackHeight
this.columns[i].stackCounter = 0
}
}
}
private tick() {
this.draw()
setTimeout(this.tick.bind(this), 50)
}
}

View File

@@ -0,0 +1,128 @@
import { Component, inject } from '@angular/core'
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'
import * as argon2 from '@start9labs/argon2'
import { ErrorService } from '@start9labs/shared'
import {
TuiButtonModule,
TuiDialogContext,
TuiErrorModule,
} from '@taiga-ui/core'
import { TuiInputPasswordModule } from '@taiga-ui/kit'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@tinkoff/ng-polymorpheus'
import {
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api.service'
interface DialogData {
target?: CifsBackupTarget | DiskBackupTarget
storageDrive?: boolean
}
@Component({
standalone: true,
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 [style.margin-top.rem]="1" (ngSubmit)="submit()">
<tui-input-password [formControl]="password">
Enter Password
<input tuiTextfield maxlength="64" />
</tui-input-password>
<tui-error [error]="passwordError"></tui-error>
@if (storageDrive) {
<tui-input-password [style.margin-top.rem]="1" [formControl]="confirm">
Retype Password
<input tuiTextfield maxlength="64" />
</tui-input-password>
<tui-error [error]="confirmError"></tui-error>
}
<footer>
<button
tuiButton
appearance="secondary"
type="button"
(click)="cancel()"
>
Cancel
</button>
<button
tuiButton
[disabled]="!password.value || !!confirmError || !!passwordError"
>
{{ storageDrive ? 'Finish' : 'Unlock' }}
</button>
</footer>
</form>
`,
styles: ['footer { display: flex; gap: 1rem; margin-top: 1rem }'],
imports: [
FormsModule,
ReactiveFormsModule,
TuiButtonModule,
TuiInputPasswordModule,
TuiErrorModule,
],
})
export class PasswordComponent {
private readonly errorService = inject(ErrorService)
private readonly context =
inject<TuiDialogContext<string, DialogData>>(POLYMORPHEUS_CONTEXT)
readonly target = this.context.data.target
readonly storageDrive = this.context.data.storageDrive
readonly password = new FormControl('', { nonNullable: true })
readonly confirm = new FormControl('', { nonNullable: true })
get passwordError(): string | null {
if (!this.password.touched || this.target) return null
if (!this.storageDrive && !this.target?.['embassy-os'])
return 'No recovery target' // unreachable
if (this.password.value.length < 12)
return 'Must be 12 characters or greater'
if (this.password.value.length > 64)
return 'Must be less than 65 characters'
return null
}
get confirmError(): string | null {
return this.confirm.touched && this.password.value !== this.confirm.value
? 'Passwords do not match'
: null
}
submit() {
if (this.storageDrive) {
this.context.completeWith(this.password.value)
return
}
try {
const passwordHash = this.target!['embassy-os']?.['password-hash'] || ''
argon2.verify(passwordHash, this.password.value)
this.context.completeWith(this.password.value)
} catch (e) {
this.errorService.handleError('Incorrect password provided')
}
}
cancel() {
this.context.$implicit.complete()
}
}
export const PASSWORD = new PolymorpheusComponent(PasswordComponent)

View File

@@ -0,0 +1,44 @@
import { Component, Input } from '@angular/core'
import { RouterLink } from '@angular/router'
import {
TuiCellModule,
TuiIconModule,
TuiTitleModule,
} from '@taiga-ui/experimental'
@Component({
standalone: true,
selector: 'app-recover',
template: `
<a tuiCell [routerLink]="disabled ? null : '/attach'">
<tui-icon icon="tuiIconBoxLarge" />
<span tuiTitle>
<span class="g-success">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="tuiIconShareLarge" />
<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="tuiIconSave" />
<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: [RouterLink, TuiIconModule, TuiCellModule, TuiTitleModule],
})
export class RecoverComponent {
@Input() disabled = false
}

View File

@@ -1,26 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { TuiButtonModule, TuiErrorModule } from '@taiga-ui/core'
import {
TuiFieldErrorPipeModule,
TuiInputModule,
TuiInputPasswordModule,
} from '@taiga-ui/kit'
import { CifsModal } from './cifs-modal.page'
@NgModule({
declarations: [CifsModal],
imports: [
CommonModule,
FormsModule,
TuiButtonModule,
TuiInputModule,
TuiErrorModule,
ReactiveFormsModule,
TuiFieldErrorPipeModule,
TuiInputPasswordModule,
],
exports: [CifsModal],
})
export class CifsModalModule {}

View File

@@ -1,39 +0,0 @@
<form [formGroup]="form" (ngSubmit)="submit()">
<tui-input formControlName="hostname">
Hostname
<input tuiTextfield placeholder="'My Computer' OR 'my-computer.local'" />
</tui-input>
<tui-error
formControlName="hostname"
[error]="['required'] | tuiFieldError | async"
></tui-error>
<tui-input formControlName="path" class="input">
Path
<input tuiTextfield placeholder="/Desktop/my-folder'" />
</tui-input>
<tui-error
formControlName="path"
[error]="[] | tuiFieldError | async"
></tui-error>
<tui-input formControlName="username" class="input">
Username
<input tuiTextfield placeholder="Enter username" />
</tui-input>
<tui-error
formControlName="username"
[error]="[] | tuiFieldError | async"
></tui-error>
<tui-input-password formControlName="password" class="input">
Password
</tui-input-password>
<footer class="modal-buttons">
<button tuiButton appearance="secondary" type="button" (click)="cancel()">
Cancel
</button>
<button tuiButton [disabled]="form.invalid">Verify</button>
</footer>
</form>

View File

@@ -1,3 +0,0 @@
.input {
margin-top: 16px;
}

View File

@@ -1,117 +0,0 @@
import { Component, Inject } from '@angular/core'
import { FormControl, FormGroup, Validators } from '@angular/forms'
import { TUI_VALIDATION_ERRORS } from '@taiga-ui/kit'
import { LoadingService, StartOSDiskInfo } from '@start9labs/shared'
import { TuiDialogContext, TuiDialogService } from '@taiga-ui/core'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import {
ApiService,
CifsBackupTarget,
CifsRecoverySource,
} from 'src/app/services/api/api.service'
import { PASSWORD } from '../password/password.page'
@Component({
selector: 'cifs-modal',
templateUrl: 'cifs-modal.page.html',
styleUrls: ['cifs-modal.page.scss'],
providers: [
{
provide: TUI_VALIDATION_ERRORS,
useValue: {
required: 'This field is required',
},
},
],
})
export class CifsModal {
readonly form = new FormGroup({
hostname: new FormControl('', {
validators: [
Validators.required,
Validators.pattern(/^[a-zA-Z0-9._-]+( [a-zA-Z0-9]+)*$/),
],
nonNullable: true,
}),
path: new FormControl('', {
validators: [Validators.required],
nonNullable: true,
}),
username: new FormControl('', {
validators: [Validators.required],
nonNullable: true,
}),
password: new FormControl(),
})
constructor(
@Inject(POLYMORPHEUS_CONTEXT)
private readonly context: TuiDialogContext<{
cifs: CifsRecoverySource
recoveryPassword: string
}>,
private readonly dialogs: TuiDialogService,
private readonly api: ApiService,
private readonly loader: LoadingService,
) {}
cancel() {
this.context.$implicit.complete()
}
async submit(): Promise<void> {
const loader = this.loader
.open('Connecting to shared folder...')
.subscribe()
try {
const diskInfo = await this.api.verifyCifs({
...this.form.getRawValue(),
type: 'cifs',
password: this.form.value.password
? await this.api.encrypt(String(this.form.value.password))
: null,
})
loader.unsubscribe()
this.presentModalPassword(diskInfo)
} catch (e) {
loader.unsubscribe()
this.presentAlertFailed()
}
}
private presentModalPassword(diskInfo: StartOSDiskInfo) {
const target: CifsBackupTarget = {
...this.form.getRawValue(),
mountable: true,
'embassy-os': diskInfo,
}
this.dialogs
.open<string>(PASSWORD, {
label: 'Unlock Drive',
size: 's',
data: { target },
})
.subscribe(recoveryPassword => {
this.context.completeWith({
cifs: { ...this.form.getRawValue(), type: 'cifs' },
recoveryPassword,
})
})
}
private presentAlertFailed() {
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.',
{
label: 'Connection Failed',
size: 's',
},
)
.subscribe()
}
}

View File

@@ -1,20 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { TuiButtonModule, TuiErrorModule } from '@taiga-ui/core'
import { TuiInputPasswordModule } from '@taiga-ui/kit'
import { PasswordPage } from './password.page'
@NgModule({
declarations: [PasswordPage],
imports: [
CommonModule,
FormsModule,
TuiButtonModule,
TuiInputPasswordModule,
TuiErrorModule,
ReactiveFormsModule,
],
exports: [PasswordPage],
})
export class PasswordPageModule {}

View File

@@ -1,35 +0,0 @@
<p *ngIf="!storageDrive else choose">
Enter the password that was used to encrypt this drive.
</p>
<ng-template #choose>
<p>
Choose a password for your server.
<i>Make it good. Write it down.</i>
</p>
</ng-template>
<form (ngSubmit)="storageDrive ? submitPw() : verifyPw()">
<tui-input-password [formControl]="password">
Enter Password
<input tuiTextfield maxlength="64" />
</tui-input-password>
<tui-error [error]="passwordError"></tui-error>
<ng-container *ngIf="storageDrive">
<tui-input-password style="margin-top: 16px" [formControl]="confirm">
Retype Password
<input tuiTextfield maxlength="64" />
</tui-input-password>
<tui-error [error]="confirmError"></tui-error>
</ng-container>
<footer class="modal-buttons">
<button tuiButton appearance="secondary" type="button" (click)="cancel()">
Cancel
</button>
<button
tuiButton
[disabled]="!password.value || !!confirmError || !!passwordError"
>
{{ storageDrive ? 'Finish' : 'Unlock' }}
</button>
</footer>
</form>

View File

@@ -1,77 +0,0 @@
import { Component, Inject } from '@angular/core'
import { FormControl } from '@angular/forms'
import * as argon2 from '@start9labs/argon2'
import { ErrorService } from '@start9labs/shared'
import { TuiDialogContext } from '@taiga-ui/core'
import {
PolymorpheusComponent,
POLYMORPHEUS_CONTEXT,
} from '@tinkoff/ng-polymorpheus'
import {
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api/api.service'
interface DialogData {
target?: CifsBackupTarget | DiskBackupTarget
storageDrive?: boolean
}
@Component({
selector: 'app-password',
templateUrl: 'password.page.html',
})
export class PasswordPage {
readonly target = this.context.data.target
readonly storageDrive = this.context.data.storageDrive
readonly password = new FormControl('', { nonNullable: true })
readonly confirm = new FormControl('', { nonNullable: true })
constructor(
@Inject(POLYMORPHEUS_CONTEXT)
private readonly context: TuiDialogContext<string, DialogData>,
private readonly errorService: ErrorService,
) {}
get passwordError(): string | null {
if (!this.password.touched || this.target) return null
if (!this.storageDrive && !this.target?.['embassy-os'])
return 'No recovery target' // unreachable
if (this.password.value.length < 12)
return 'Must be 12 characters or greater'
if (this.password.value.length > 64)
return 'Must be less than 65 characters'
return null
}
get confirmError(): string | null {
return this.confirm.touched && this.password.value !== this.confirm.value
? 'Passwords do not match'
: null
}
verifyPw() {
try {
const passwordHash = this.target!['embassy-os']?.['password-hash'] || ''
argon2.verify(passwordHash, this.password.value)
this.context.completeWith(this.password.value)
} catch (e) {
this.errorService.handleError('Incorrect password provided')
}
}
submitPw() {
this.context.completeWith(this.password.value)
}
cancel() {
this.context.$implicit.complete()
}
}
export const PASSWORD = new PolymorpheusComponent(PasswordPage)

View File

@@ -0,0 +1,108 @@
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import {
DiskInfo,
DriveComponent,
ErrorService,
LoadingService,
toGuid,
} from '@start9labs/shared'
import { TuiDialogService, TuiLoaderModule } from '@taiga-ui/core'
import {
TuiButtonModule,
TuiCardModule,
TuiCellModule,
} from '@taiga-ui/experimental'
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({
standalone: true,
template: `
<section tuiCardLarge>
<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.
}
<button tuiButton iconLeft="tuiIconRotateCwLarge" (click)="refresh()">
Refresh
</button>
}
</section>
`,
imports: [
TuiButtonModule,
TuiCardModule,
TuiCellModule,
TuiLoaderModule,
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...').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

@@ -1,16 +0,0 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { AttachPage } from './attach.page'
const routes: Routes = [
{
path: '',
component: AttachPage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AttachPageRoutingModule {}

View File

@@ -1,21 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import {
GuidPipePipesModule,
UnitConversionPipesModule,
} from '@start9labs/shared'
import { AttachPage } from './attach.page'
import { AttachPageRoutingModule } from './attach-routing.module'
@NgModule({
declarations: [AttachPage],
imports: [
CommonModule,
IonicModule,
AttachPageRoutingModule,
UnitConversionPipesModule,
GuidPipePipesModule,
],
})
export class AttachPageModule {}

View File

@@ -1,67 +0,0 @@
<ion-content>
<ion-grid>
<ion-row class="ion-align-items-center">
<ion-col>
<ion-card color="dark">
<ion-card-header class="ion-text-center">
<ion-card-title>Use existing drive</ion-card-title>
<div class="center-wrapper">
<ion-card-subtitle>
Select the physical drive containing your StartOS data
</ion-card-subtitle>
</div>
</ion-card-header>
<ion-card-content class="ion-margin">
<ion-spinner
*ngIf="loading"
class="center-spinner"
name="lines"
></ion-spinner>
<!-- loaded -->
<ion-item-group *ngIf="!loading" class="ion-text-center">
<!-- drives -->
<p *ngIf="!drives.length">
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.
</p>
<ng-container *ngFor="let drive of drives">
<ion-item
*ngIf="drive | guid as guid"
button
(click)="select(guid)"
lines="none"
>
<ion-icon
slot="start"
name="save-outline"
size="large"
></ion-icon>
<ion-label>
<h1>{{ drive.logicalname }}</h1>
<p>
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model ||
'Unknown Model' }}
</p>
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
</ion-label>
</ion-item>
</ng-container>
<ion-button
class="ion-margin-top"
color="primary"
(click)="refresh()"
>
<ion-icon slot="start" name="refresh"></ion-icon>
Refresh
</ion-button>
</ion-item-group>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -1,71 +0,0 @@
import { Component } from '@angular/core'
import { NavController } from '@ionic/angular'
import { DiskInfo, ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { ApiService } from 'src/app/services/api/api.service'
import { StateService } from 'src/app/services/state.service'
import { PASSWORD, PasswordPage } from 'src/app/modals/password/password.page'
@Component({
selector: 'app-attach',
templateUrl: 'attach.page.html',
styleUrls: ['attach.page.scss'],
})
export class AttachPage {
loading = true
drives: DiskInfo[] = []
constructor(
private readonly apiService: ApiService,
private readonly navCtrl: NavController,
private readonly errorService: ErrorService,
private readonly stateService: StateService,
private readonly dialogs: TuiDialogService,
private readonly loader: LoadingService,
) {}
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()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
select(guid: string) {
this.dialogs
.open<string>(PASSWORD, {
label: 'Set Password',
size: 's',
data: { storageDrive: true },
})
.subscribe(password => {
this.attachDrive(guid, password)
})
}
private async attachDrive(guid: string, password: string) {
const loader = this.loader.open('Connecting to drive...').subscribe()
try {
await this.stateService.importDrive(guid, password)
await this.navCtrl.navigateForward(`/loading`)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,16 +0,0 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { EmbassyPage } from './embassy.page'
const routes: Routes = [
{
path: '',
component: EmbassyPage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class EmbassyPageRoutingModule { }

View File

@@ -1,25 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import {
GuidPipePipesModule,
UnitConversionPipesModule,
} from '@start9labs/shared'
import { EmbassyPage } from './embassy.page'
import { PasswordPageModule } from '../../modals/password/password.module'
import { EmbassyPageRoutingModule } from './embassy-routing.module'
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
EmbassyPageRoutingModule,
PasswordPageModule,
UnitConversionPipesModule,
GuidPipePipesModule,
],
declarations: [EmbassyPage],
})
export class EmbassyPageModule {}

View File

@@ -1,87 +0,0 @@
<ion-content>
<ion-grid>
<ion-row class="ion-align-items-center">
<ion-col class="ion-text-center">
<ion-card color="dark">
<ion-card-header
class="ion-text-center"
style="padding-bottom: 8px"
*ngIf="loading || storageDrives.length; else empty"
>
<ion-card-title>Select storage drive</ion-card-title>
<div class="center-wrapper">
<ion-card-subtitle>
This is the drive where your StartOS data will be stored.
</ion-card-subtitle>
</div>
</ion-card-header>
<ng-template #empty>
<ion-card-header
class="ion-text-center"
style="padding-bottom: 8px"
>
<ion-card-title>No drives found</ion-card-title>
<div class="center-wrapper">
<ion-card-subtitle>
Please connect a storage drive to your server. Then click
"Refresh".
</ion-card-subtitle>
</div>
</ion-card-header>
</ng-template>
<ion-card-content class="ion-margin">
<!-- loading -->
<ion-spinner
*ngIf="loading; else loaded"
class="center-spinner"
name="lines-sharp"
></ion-spinner>
<!-- not loading -->
<ng-template #loaded>
<ion-item-group
*ngIf="storageDrives.length"
class="ion-padding-bottom"
>
<ion-item
(click)="chooseDrive(drive)"
class="ion-margin-bottom"
[disabled]="tooSmall(drive)"
button
lines="none"
*ngFor="let drive of storageDrives"
>
<ion-icon
slot="start"
name="save-outline"
size="large"
></ion-icon>
<ion-label class="ion-text-wrap">
<h1>
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model ||
'Unknown Model' }}
</h1>
<h2>
{{ drive.logicalname }} - {{ drive.capacity | convertBytes
}}
</h2>
<p *ngIf="tooSmall(drive)">
<ion-text color="danger">
Drive capacity too small.
</ion-text>
</p>
</ion-label>
</ion-item>
</ion-item-group>
<ion-button fill="solid" color="primary" (click)="getDrives()">
<ion-icon slot="start" name="refresh"></ion-icon>
Refresh
</ion-button>
</ng-template>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,144 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core'
import { RouterLink } from '@angular/router'
import { ErrorService } from '@start9labs/shared'
import {
TuiButtonModule,
TuiCardModule,
TuiCellModule,
TuiIconModule,
TuiIconsModule,
TuiTitleModule,
} from '@taiga-ui/experimental'
import { RecoverComponent } from 'src/app/components/recover.component'
import { ApiService } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
@Component({
standalone: true,
template: `
<img class="logo" src="assets/img/icon.png" alt="Start9" />
@if (!loading) {
<section tuiCardLarge>
<header [style.padding-top.rem]="1.25">
@if (recover) {
<button
tuiIconButton
appearance="flat"
class="back"
iconLeft="tuiIconChevronLeft"
(click)="recover = false"
>
Back
</button>
}
{{ recover ? 'StartOS Setup' : 'Recover Options' }}
</header>
<div class="pages">
<div class="options" [class.options_recover]="recover">
<a tuiCell [routerLink]="error || recover ? null : '/storage'">
<tui-icon icon="tuiIconPlus" />
<span tuiTitle>
<span class="g-success">Start Fresh</span>
<span tuiSubtitle>
Get started with a brand new Start9 server
</span>
</span>
</a>
<button
tuiCell
[disabled]="error || recover"
(click)="recover = true"
>
<tui-icon icon="tuiIconRotateCw" />
<span tuiTitle>
<span class="g-warning">Recover</span>
<span tuiSubtitle>
Recover, restore, or transfer StartOS data
</span>
</span>
</button>
</div>
<app-recover class="options" [disabled]="!recover" />
</div>
</section>
}
`,
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';
.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 transition(margin);
min-width: 100%;
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1rem;
box-sizing: border-box;
&_recover {
margin-left: -100%;
}
}
`,
imports: [
CommonModule,
RouterLink,
TuiCardModule,
TuiButtonModule,
TuiIconsModule,
TuiCellModule,
TuiIconModule,
TuiTitleModule,
RecoverComponent,
],
})
export default class HomePage implements OnInit {
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly stateService = inject(StateService)
error = false
loading = true
recover = false
async ngOnInit() {
this.stateService.setupType = 'fresh'
try {
await this.api.getPubKey()
} catch (e: any) {
this.error = true
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
}

View File

@@ -1,16 +0,0 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { HomePage } from './home.page'
const routes: Routes = [
{
path: '',
component: HomePage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class HomePageRoutingModule { }

View File

@@ -1,21 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { HomePage } from './home.page'
import { PasswordPageModule } from '../../modals/password/password.module'
import { HomePageRoutingModule } from './home-routing.module'
import { SwiperModule } from 'swiper/angular'
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
HomePageRoutingModule,
PasswordPageModule,
SwiperModule,
],
declarations: [HomePage],
})
export class HomePageModule {}

View File

@@ -1,129 +0,0 @@
<ion-content>
<ion-grid *ngIf="!loading">
<ion-row class="ion-align-items-center">
<ion-col class="ion-text-center">
<div style="padding-bottom: 32px">
<img
src="assets/img/icon.png"
class="pb-1"
style="max-width: 100px"
/>
</div>
<ion-card color="dark">
<ion-card-header style="padding-bottom: 0">
<ion-button
*ngIf="swiper?.activeIndex === 1"
class="back-button"
fill="clear"
color="light"
(click)="previous()"
>
<ion-icon slot="icon-only" name="arrow-back"></ion-icon>
</ion-button>
<ion-card-title>
{{ swiper?.activeIndex === 0 ? 'StartOS Setup' : 'Recover Options'
}}
</ion-card-title>
</ion-card-header>
<ion-card-content class="ion-margin-bottom">
<swiper
[autoHeight]="true"
[observeParents]="true"
(swiper)="setSwiperInstance($event)"
>
<!-- SLIDE 1 -->
<ng-template swiperSlide>
<!-- fresh -->
<ion-item
button
[disabled]="error"
detail="false"
lines="none"
routerLink="/storage"
>
<ion-icon slot="start" name="add"></ion-icon>
<ion-label>
<h2><ion-text color="success">Start Fresh</ion-text></h2>
<p>Get started with a brand new Start9 server</p>
</ion-label>
</ion-item>
<!-- recover -->
<ion-item
button
[disabled]="error"
detail="false"
lines="none"
(click)="next()"
>
<ion-icon slot="start" name="reload"></ion-icon>
<ion-label>
<h2><ion-text color="danger">Recover</ion-text></h2>
<p>Recover, restore, or transfer StartOS data</p>
</ion-label>
</ion-item>
</ng-template>
<!-- SLIDE 2 -->
<ng-template swiperSlide>
<!-- attach -->
<ion-item
button
detail="false"
lines="none"
routerLink="/attach"
>
<ion-icon slot="start" name="cube-outline"></ion-icon>
<ion-label>
<h2>
<ion-text color="success">Use Existing Drive</ion-text>
</h2>
<p>Attach an existing StartOS data drive (not a backup)</p>
</ion-label>
</ion-item>
<!-- transfer -->
<ion-item
button
detail="false"
lines="none"
routerLink="/transfer"
>
<ion-icon slot="start" name="share-outline"></ion-icon>
<ion-label>
<h2>
<ion-text color="primary">Transfer</ion-text>
</h2>
<p>
Transfer data from an existing StartOS data drive (not a
backup) to a new, preferred drive
<br />
</p>
</ion-label>
</ion-item>
<!-- restore from backup -->
<ion-item
button
lines="none"
detail="false"
routerLink="/recover"
>
<ion-icon slot="start" name="save-outline"></ion-icon>
<ion-label>
<h2>
<ion-text color="warning">
Restore From Backup (Disaster Recovery)
</ion-text>
</h2>
<p>Restore StartOS data from an encrypted backup</p>
</ion-label>
</ion-item>
</ng-template>
</swiper>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -1,13 +0,0 @@
.back-button {
position: absolute;
left: 16px;
top: 24px;
z-index: 1000000;
}
.inline {
display: flex;
align-items: center;
text-align: center;
justify-content: center;
}

View File

@@ -1,53 +0,0 @@
import { Component } from '@angular/core'
import { IonicSlides } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/api.service'
import SwiperCore, { Swiper } from 'swiper'
import { ErrorService } from '@start9labs/shared'
import { StateService } from 'src/app/services/state.service'
SwiperCore.use([IonicSlides])
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage {
swiper?: Swiper
error = false
loading = true
constructor(
private readonly api: ApiService,
private readonly errorService: ErrorService,
private readonly stateService: StateService,
) {}
async ionViewDidEnter() {
this.stateService.setupType = 'fresh'
if (this.swiper) {
this.swiper.allowTouchMove = false
}
try {
await this.api.getPubKey()
} catch (e: any) {
this.error = true
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
setSwiperInstance(swiper: any) {
this.swiper = swiper
}
next() {
this.swiper?.slideNext(500)
}
previous() {
this.swiper?.slidePrev(500)
}
}

View File

@@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { InitializingComponent } from '@start9labs/shared'
import { StateService } from 'src/app/services/state.service'
@Component({
standalone: true,
template: `
<app-initializing
[setupType]="stateService.setupType"
(finished)="router.navigate(['success'])"
/>
`,
imports: [InitializingComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class LoadingPage {
readonly stateService = inject(StateService)
readonly router = inject(Router)
}

View File

@@ -1,17 +0,0 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { InitializingModule } from '@start9labs/shared'
import { LoadingPage } from './loading.page'
const routes: Routes = [
{
path: '',
component: LoadingPage,
},
]
@NgModule({
imports: [InitializingModule, RouterModule.forChild(routes)],
declarations: [LoadingPage],
})
export class LoadingPageModule {}

View File

@@ -1,5 +0,0 @@
<app-initializing
class="ion-page"
[setupType]="stateService.setupType"
(finished)="navCtrl.navigateForward('/success')"
></app-initializing>

View File

@@ -1,13 +0,0 @@
import { Component } from '@angular/core'
import { NavController } from '@ionic/angular'
import { StateService } from 'src/app/services/state.service'
@Component({
templateUrl: 'loading.page.html',
})
export class LoadingPage {
constructor(
readonly stateService: StateService,
readonly navCtrl: NavController,
) {}
}

View File

@@ -0,0 +1,163 @@
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { DriveComponent, ErrorService } from '@start9labs/shared'
import { TuiDialogService, TuiLoaderModule } from '@taiga-ui/core'
import {
TuiButtonModule,
TuiCardModule,
TuiCellModule,
TuiIconModule,
TuiTitleModule,
} from '@taiga-ui/experimental'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { filter } from 'rxjs'
import { CifsComponent } from 'src/app/components/cifs.component'
import { PASSWORD } from 'src/app/components/password.component'
import {
ApiService,
CifsRecoverySource,
DiskBackupTarget,
} from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
@Component({
standalone: true,
template: `
<section tuiCardLarge>
<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 (click)="onCifs()">
<tui-icon icon="tuiIconFolder" />
<span tuiTitle>Open</span>
</button>
<h2>Physical Drive</h2>
Restore StartOS data from a physical drive that is plugged directly into
your server.
<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 (d of drives; track d) {
<button tuiCell [drive]="d" [disabled]="empty(d)" (click)="select(d)">
<span tuiSubtitle>
@if (empty(d)) {
<tui-icon icon="tuiIconCloudOff" class="g-error" />
<strong>No StartOS backup</strong>
} @else {
<tui-icon icon="tuiIconCloud" class="g-success" />
<strong>StartOS backup detected</strong>
}
</span>
</button>
}
<button tuiButton iconLeft="tuiIconRotateCwLarge" (click)="refresh()">
Refresh
</button>
}
</section>
`,
imports: [
TuiCardModule,
TuiLoaderModule,
TuiButtonModule,
TuiCellModule,
TuiIconModule,
TuiTitleModule,
DriveComponent,
],
})
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
drives: DiskBackupTarget[] = []
async ngOnInit() {
this.stateService.setupType = 'restore'
await this.getDrives()
}
async refresh() {
this.loading = true
await this.getDrives()
}
empty(drive: DiskBackupTarget) {
return !drive['embassy-os']?.full
}
async getDrives() {
this.drives = []
try {
await this.api.getDrives().then(disks =>
disks
.filter(d => d.partitions.length)
.forEach(d => {
d.partitions.forEach(p => {
this.drives.push({ ...d, ...p })
})
}),
)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
select(target: DiskBackupTarget) {
const { logicalname } = target
if (!logicalname) return
this.dialogs
.open<string>(PASSWORD, {
label: 'Unlock Drive',
size: 's',
data: { target },
})
.pipe(filter(Boolean))
.subscribe(password => {
this.onSource(logicalname, password)
})
}
onCifs() {
this.dialogs
.open<{
cifs: CifsRecoverySource
recoveryPassword: string
}>(new PolymorpheusComponent(CifsComponent), {
label: 'Connect Network Folder',
})
.subscribe(({ cifs, recoveryPassword }) => {
this.stateService.recoverySource = { type: 'backup', target: cifs }
this.stateService.recoveryPassword = recoveryPassword
this.router.navigate(['storage'])
})
}
private onSource(logicalname: string, password?: string) {
this.stateService.recoverySource = {
type: 'backup',
target: { type: 'disk', logicalname },
}
this.stateService.recoveryPassword = password
this.router.navigate(['storage'])
}
}

View File

@@ -1,14 +0,0 @@
<div class="inline">
<!-- has backup -->
<h2 *ngIf="hasValidBackup; else noBackup">
<ion-icon name="cloud-done" color="success"></ion-icon>
StartOS backup detected
</h2>
<!-- no backup -->
<ng-template #noBackup>
<h2>
<ion-icon name="cloud-offline" color="danger"></ion-icon>
No StartOS backup
</h2>
</ng-template>
</div>

View File

@@ -1,16 +0,0 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { RecoverPage } from './recover.page'
const routes: Routes = [
{
path: '',
component: RecoverPage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class RecoverPageRoutingModule { }

View File

@@ -1,23 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { UnitConversionPipesModule } from '@start9labs/shared'
import { DriveStatusComponent, RecoverPage } from './recover.page'
import { PasswordPageModule } from '../../modals/password/password.module'
import { RecoverPageRoutingModule } from './recover-routing.module'
import { CifsModalModule } from 'src/app/modals/cifs-modal/cifs-modal.module'
@NgModule({
declarations: [RecoverPage, DriveStatusComponent],
imports: [
CommonModule,
FormsModule,
IonicModule,
RecoverPageRoutingModule,
PasswordPageModule,
UnitConversionPipesModule,
CifsModalModule,
],
})
export class RecoverPageModule {}

View File

@@ -1,97 +0,0 @@
<ion-content>
<ion-grid>
<ion-row class="ion-align-items-center">
<ion-col>
<ion-card color="dark">
<ion-card-header class="ion-text-center">
<ion-card-title>Restore from Backup</ion-card-title>
</ion-card-header>
<ion-card-content class="ion-margin">
<ion-spinner
*ngIf="loading"
class="center-spinner"
name="lines"
></ion-spinner>
<!-- loaded -->
<ion-item-group *ngIf="!loading" class="ion-text-center">
<!-- cifs -->
<h2 class="target-label">Network Folder</h2>
<p class="ion-padding-bottom ion-text-left">
Restore StartOS data from a folder on another computer that is
connected to the same network as your server.
</p>
<!-- connect -->
<ion-item button lines="none" (click)="presentModalCifs()">
<ion-icon
slot="start"
name="folder-open-outline"
size="large"
></ion-icon>
<ion-label>
<b>Open</b>
</ion-label>
</ion-item>
<br />
<br />
<!-- drives -->
<h2 class="target-label">Physical Drive</h2>
<div class="ion-text-left ion-padding-bottom">
<p>
Restore StartOS data from a physical drive that is plugged
directly into your server.
</p>
<br />
<b>
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.
</b>
</div>
<ng-container *ngFor="let mapped of mappedDrives">
<ion-item
button
*ngIf="mapped.drive as drive"
[disabled]="!driveClickable(mapped)"
(click)="select(drive)"
lines="none"
>
<ion-icon
slot="start"
name="save-outline"
size="large"
></ion-icon>
<ion-label>
<h1>{{ drive.label || drive.logicalname }}</h1>
<drive-status
[hasValidBackup]="mapped.hasValidBackup"
></drive-status>
<p>
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model ||
'Unknown Model' }}
</p>
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
</ion-label>
</ion-item>
</ng-container>
<ion-button
class="ion-margin-top"
color="primary"
(click)="refresh()"
>
<ion-icon slot="start" name="refresh"></ion-icon>
Refresh
</ion-button>
</ion-item-group>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -1,5 +0,0 @@
.target-label {
font-weight: 500;
padding-bottom: 6px;
font-variant-caps: all-small-caps;
}

View File

@@ -1,137 +0,0 @@
import { Component, Input } from '@angular/core'
import { NavController } from '@ionic/angular'
import { CifsModal } from 'src/app/modals/cifs-modal/cifs-modal.page'
import {
ApiService,
CifsRecoverySource,
DiskBackupTarget,
} from 'src/app/services/api/api.service'
import { ErrorService } from '@start9labs/shared'
import { StateService } from 'src/app/services/state.service'
import { PASSWORD } from '../../modals/password/password.page'
import { TuiDialogService } from '@taiga-ui/core'
import { filter } from 'rxjs'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
@Component({
selector: 'app-recover',
templateUrl: 'recover.page.html',
styleUrls: ['recover.page.scss'],
})
export class RecoverPage {
loading = true
mappedDrives: MappedDisk[] = []
constructor(
private readonly apiService: ApiService,
private readonly navCtrl: NavController,
private readonly dialogs: TuiDialogService,
private readonly errorService: ErrorService,
private readonly stateService: StateService,
) {}
async ngOnInit() {
this.stateService.setupType = 'restore'
await this.getDrives()
}
async refresh() {
this.loading = true
await this.getDrives()
}
driveClickable(mapped: MappedDisk) {
return mapped.drive['embassy-os']?.full
}
async getDrives() {
this.mappedDrives = []
try {
const disks = await this.apiService.getDrives()
disks
.filter(d => d.partitions.length)
.forEach(d => {
d.partitions.forEach(p => {
const drive: DiskBackupTarget = {
vendor: d.vendor,
model: d.model,
logicalname: p.logicalname,
label: p.label,
capacity: p.capacity,
used: p.used,
'embassy-os': p['embassy-os'],
}
this.mappedDrives.push({
hasValidBackup: !!p['embassy-os']?.full,
drive,
})
})
})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
presentModalCifs() {
this.dialogs
.open<{ cifs: CifsRecoverySource; recoveryPassword: string }>(
new PolymorpheusComponent(CifsModal),
{
label: 'Connect Network Folder',
},
)
.subscribe(({ cifs, recoveryPassword }) => {
this.stateService.recoverySource = {
type: 'backup',
target: cifs,
}
this.stateService.recoveryPassword = recoveryPassword
this.navCtrl.navigateForward('/storage')
})
}
async select(target: DiskBackupTarget) {
const { logicalname } = target
if (!logicalname) return
this.dialogs
.open<string>(PASSWORD, {
label: 'Unlock Drive',
size: 's',
data: { target },
})
.pipe(filter(Boolean))
.subscribe(password => {
this.selectRecoverySource(logicalname, password)
})
}
private async selectRecoverySource(logicalname: string, password?: string) {
this.stateService.recoverySource = {
type: 'backup',
target: {
type: 'disk',
logicalname,
},
}
this.stateService.recoveryPassword = password
this.navCtrl.navigateForward(`/storage`)
}
}
@Component({
selector: 'drive-status',
templateUrl: './drive-status.component.html',
styleUrls: ['./recover.page.scss'],
})
export class DriveStatusComponent {
@Input({ required: true }) hasValidBackup!: boolean
}
interface MappedDisk {
hasValidBackup: boolean
drive: DiskBackupTarget
}

View File

@@ -1,49 +1,83 @@
import { Component } from '@angular/core'
import { NavController } from '@ionic/angular'
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import {
DiskInfo,
DriveComponent,
ErrorService,
GuidPipe,
LoadingService,
toGuid,
} from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { TuiDialogService, TuiLoaderModule } from '@taiga-ui/core'
import {
TuiButtonModule,
TuiCardModule,
TuiCellModule,
} from '@taiga-ui/experimental'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { filter, of, switchMap } from 'rxjs'
import { PASSWORD } from 'src/app/components/password.component'
import {
ApiService,
BackupRecoverySource,
DiskRecoverySource,
DiskMigrateSource,
} from 'src/app/services/api/api.service'
DiskRecoverySource,
} from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
import { PASSWORD, PasswordPage } from '../../modals/password/password.page'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { filter, of, switchMap } from 'rxjs'
@Component({
selector: 'app-embassy',
templateUrl: 'embassy.page.html',
styleUrls: ['embassy.page.scss'],
providers: [GuidPipe],
})
export class EmbassyPage {
storageDrives: DiskInfo[] = []
loading = true
standalone: true,
template: `
<section tuiCardLarge>
@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".
}
constructor(
private readonly apiService: ApiService,
private readonly navCtrl: NavController,
private readonly dialogs: TuiDialogService,
private readonly stateService: StateService,
private readonly loader: LoadingService,
private readonly errorService: ErrorService,
private readonly guidPipe: GuidPipe,
) {}
@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-error">Drive capacity too small</span>
}
</button>
}
<button tuiButton iconLeft="tuiIconRotateCwLarge" (click)="getDrives()">
Refresh
</button>
</section>
`,
imports: [
TuiCardModule,
TuiLoaderModule,
TuiCellModule,
TuiButtonModule,
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()
}
tooSmall(drive: DiskInfo) {
return drive.capacity < 34359738368
isSmall({ capacity }: DiskInfo) {
return capacity < 34359738368
}
async refresh() {
@@ -54,11 +88,11 @@ export class EmbassyPage {
async getDrives() {
this.loading = true
try {
const disks = await this.apiService.getDrives()
const disks = await this.api.getDrives()
if (this.stateService.setupType === 'fresh') {
this.storageDrives = disks
this.drives = disks
} else if (this.stateService.setupType === 'restore') {
this.storageDrives = disks.filter(
this.drives = disks.filter(
d =>
!d.partitions
.map(p => p.logicalname)
@@ -72,7 +106,7 @@ export class EmbassyPage {
} else if (this.stateService.setupType === 'transfer') {
const guid = (this.stateService.recoverySource as DiskMigrateSource)
.guid
this.storageDrives = disks.filter(d => {
this.drives = disks.filter(d => {
return (
d.guid !== guid && !d.partitions.map(p => p.guid).includes(guid)
)
@@ -85,8 +119,8 @@ export class EmbassyPage {
}
}
chooseDrive(drive: DiskInfo) {
of(!this.guidPipe.transform(drive) && !drive.partitions.some(p => p.used))
select(drive: DiskInfo) {
of(!toGuid(drive) && !drive.partitions.some(p => p.used))
.pipe(
switchMap(unused =>
unused
@@ -138,7 +172,7 @@ export class EmbassyPage {
try {
await this.stateService.setupEmbassy(logicalname, password)
await this.navCtrl.navigateForward(`/loading`)
await this.router.navigate([`loading`])
} catch (e: any) {
this.errorService.handleError(e)
} finally {

View File

@@ -0,0 +1,175 @@
import { DOCUMENT } from '@angular/common'
import {
AfterViewInit,
Component,
ElementRef,
inject,
ViewChild,
} from '@angular/core'
import { DownloadHTMLService, ErrorService } from '@start9labs/shared'
import {
TuiButtonModule,
TuiCardModule,
TuiIconModule,
TuiSurfaceModule,
} from '@taiga-ui/experimental'
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'
@Component({
standalone: true,
template: `
<canvas matrix></canvas>
@if (isKiosk) {
<section tuiCardLarge>
<h1 class="heading">
<tui-icon icon="tuiIconCheckSquare" class="g-success" />
Setup Complete!
</h1>
<button tuiButton (click)="exitKiosk()" iconRight="tuiIconLogInLarge">
Continue to Login
</button>
</section>
} @else if (lanAddress) {
<section tuiCardLarge>
<h1 class="heading">
<tui-icon icon="tuiIconCheckSquare" class="g-success" />
Setup Complete!
</h1>
@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>
}
<button tuiCardLarge tuiSurface="elevated" (click)="download()">
<strong class="caps">Download address info</strong>
<span>
start.local was for setup purposes only. It will no longer work.
</span>
<strong class="caps">
Download
<tui-icon icon="tuiIconDownload" />
</strong>
</button>
<a
tuiCardLarge
tuiSurface="elevated"
target="_blank"
[attr.href]="disableLogin ? null : lanAddress"
>
<strong class="caps">Trust your Root CA</strong>
<span>
In the new tab, follow instructions to trust your server's Root CA
and log in.
</span>
<strong class="caps">
Open
<tui-icon icon="tuiIconExternalLink" />
</strong>
</a>
<app-documentation hidden [lanAddress]="lanAddress" />
</section>
}
`,
styles: `
.heading {
display: flex;
gap: 1rem;
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-01);
text-decoration: none;
text-align: center;
}
a[tuiCardLarge]:not([href]) {
opacity: var(--tui-disabled-opacity);
pointer-events: none;
}
`,
imports: [
TuiCardModule,
TuiIconModule,
TuiButtonModule,
TuiSurfaceModule,
MatrixComponent,
DocumentationComponent,
],
})
export default class SuccessPage implements AfterViewInit {
@ViewChild(DocumentationComponent, { read: ElementRef })
private readonly documentation?: ElementRef<HTMLElement>
private readonly document = inject(DOCUMENT)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly downloadHtml = inject(DownloadHTMLService)
readonly stateService = inject(StateService)
readonly isKiosk = ['localhost', '127.0.0.1'].includes(
this.document.location.hostname,
)
torAddress?: string
lanAddress?: string
cert?: string
disableLogin = this.stateService.setupType === 'fresh'
ngAfterViewInit() {
setTimeout(() => this.complete(), 1000)
}
download() {
const torAddress = this.document.getElementById('tor-addr')
const lanAddress = this.document.getElementById('lan-addr')
const html = this.documentation?.nativeElement.innerHTML || ''
if (torAddress) torAddress.innerHTML = this.torAddress!
if (lanAddress) lanAddress.innerHTML = this.lanAddress!
this.document
.getElementById('cert')
?.setAttribute(
'href',
`data:application/x-x509-ca-cert;base64,${encodeURIComponent(this.cert!)}`,
)
this.downloadHtml.download('StartOS-info.html', html).then(_ => {
this.disableLogin = false
})
}
exitKiosk() {
this.api.exit()
}
private async complete() {
try {
const ret = await this.api.complete()
if (!this.isKiosk) {
this.torAddress = ret['tor-address'].replace(/^https:/, 'http:')
this.lanAddress = ret['lan-address'].replace(/^https:/, 'http:')
this.cert = ret['root-ca']
await this.api.exit()
}
} catch (e: any) {
this.errorService.handleError(e)
}
}
}

View File

@@ -1,129 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>StartOS Address Info</title>
</head>
<body>
<div
style="
font-family: Montserrat, sans-serif;
color: #333333;
display: flex;
flex-direction: column;
margin: auto;
width: clamp(900px, 35vw, 600px);
"
>
<h1
style="
font-variant-caps: all-small-caps;
text-align: center;
padding: 1rem;
"
>
StartOS Address Info
</h1>
<section
style="
padding: 1rem 3rem 2rem 3rem;
margin-bottom: 24px;
border: solid #c4c4c5 3px;
border-radius: 20px;
"
>
<div>
<h3 style="color: #f8546a; font-weight: bold">Important!</h3>
<p>
Download your server's Root CA and
<a
href="https://docs.start9.com/0.3.5.x/user-manual/connecting-lan"
target="_blank"
rel="noreferrer"
style="color: #6866cc; font-weight: bold; text-decoration: none"
>
follow the instructions
</a>
to establish a secure connection with your server.
</p>
</div>
<div style="text-align: center">
<a
id="cert"
[download]="crtName"
style="
display: inline-block;
padding: 1em 1.2em;
box-sizing: border-box;
font-size: 1rem;
text-decoration: none;
text-align: center;
border-radius: clamp(2rem, 3rem, 4rem);
cursor: pointer;
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px,
rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
background: #6866cc;
color: #f4f4f5;
"
>
Download certificate
</a>
</div>
</section>
<section
style="
padding: 1rem 3rem 2rem 3rem;
border: solid #c4c4c5 3px;
border-radius: 20px;
margin-bottom: 24px;
"
>
<h2 style="font-variant-caps: all-small-caps">
Access from home (LAN)
</h2>
<p>
Visit the address below when you are connected to the same WiFi or
Local Area Network (LAN) as your server.
</p>
<p
style="
padding: 16px;
font-weight: bold;
font-size: 1.1rem;
overflow: auto;
"
>
<code id="lan-addr"></code>
</p>
<h2 style="font-variant-caps: all-small-caps">
Access on the go (Tor)
</h2>
<p>Visit the address below when you are away from home.</p>
<p>
<span style="font-weight: bold">Note:</span>
This address will only work from a Tor-enabled browser.
<a
href="https://docs.start9.com/0.3.5.x/user-manual/connecting-tor"
target="_blank"
rel="noreferrer"
style="color: #6866cc; font-weight: bold; text-decoration: none"
>
Follow the instructions
</a>
to get setup.
</p>
<p
style="
padding: 16px;
font-weight: bold;
font-size: 1.1rem;
overflow: auto;
"
>
<code id="tor-addr"></code>
</p>
</section>
</div>
</body>
</html>

View File

@@ -1,14 +0,0 @@
import { Component, Input } from '@angular/core'
@Component({
selector: 'download-doc',
templateUrl: 'download-doc.component.html',
})
export class DownloadDocComponent {
@Input({ required: true }) lanAddress!: string
get crtName(): string {
const hostname = new URL(this.lanAddress).hostname
return `${hostname}.crt`
}
}

View File

@@ -1,16 +0,0 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { SuccessPage } from './success.page'
const routes: Routes = [
{
path: '',
component: SuccessPage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class SuccessPageRoutingModule {}

View File

@@ -1,24 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { ResponsiveColDirective } from '@start9labs/shared'
import { SuccessPage } from './success.page'
import { PasswordPageModule } from '../../modals/password/password.module'
import { SuccessPageRoutingModule } from './success-routing.module'
import { DownloadDocComponent } from './download-doc/download-doc.component'
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
PasswordPageModule,
SuccessPageRoutingModule,
ResponsiveColDirective,
],
declarations: [SuccessPage, DownloadDocComponent],
exports: [SuccessPage],
})
export class SuccessPageModule {}

View File

@@ -1,102 +0,0 @@
<canvas #canvas>Your browser does not support the canvas element.</canvas>
<ion-content>
<ion-grid>
<ion-row class="ion-align-items-center">
<ion-col>
<!-- kiosk mode -->
<ng-container *ngIf="isKiosk; else notKiosk">
<ion-card>
<ion-row class="ion-align-items-center">
<ion-col responsiveCol sizeXs="12" class="ion-text-center">
<div class="inline mb-12">
<ion-icon
name="checkmark-circle-outline"
color="success"
></ion-icon>
<h1>Setup Complete!</h1>
</div>
<ion-button
shape="round"
class="login-button mb-12"
(click)="exitKiosk()"
>
Continue to Login
<ion-icon name="log-in-outline" slot="end"></ion-icon>
</ion-button>
</ion-col>
</ion-row>
</ion-card>
</ng-container>
<!-- not kiosk -->
<ng-template #notKiosk>
<ion-card *ngIf="lanAddress">
<ion-row class="ion-align-items-center">
<ion-col responsiveCol sizeXs="12" class="ion-text-center">
<div class="mb-12">
<div class="inline-container setup">
<ion-icon
name="checkmark-circle-outline"
color="success"
></ion-icon>
<h1>Setup Complete!</h1>
</div>
<h3 *ngIf="setupType === 'restore'">
You can now safely unplug your backup drive
</h3>
<h3 *ngIf="setupType === 'transfer'">
You can now safely unplug your old StartOS data drive
</h3>
</div>
<div class="card-container">
<ion-card id="information" (click)="download()">
<ion-card-content>
<ion-card-title>Download address info</ion-card-title>
<p>
start.local was for setup purposes only. It will no
longer work.
</p>
</ion-card-content>
<ion-footer>
<div class="inline-container">
<p class="action-text">Download</p>
<ion-icon slot="end" name="download-outline"></ion-icon>
</div>
</ion-footer>
</ion-card>
<ion-card
id="launch"
[disabled]="disableLogin"
href="{{ lanAddress }}"
target="_blank"
>
<ion-card-content>
<ion-card-title>Trust your Root CA</ion-card-title>
<p>
In the new tab, follow instructions to trust your
server's Root CA and log in.
</p>
</ion-card-content>
<ion-footer>
<div class="container">
<div class="inline-container">
<p class="action-text">Open</p>
<ion-icon slot="end" name="open-outline"></ion-icon>
</div>
</div>
</ion-footer>
</ion-card>
</div>
</ion-col>
</ion-row>
<!-- download elem -->
<download-doc
hidden
id="downloadable"
[lanAddress]="lanAddress"
></download-doc>
</ion-card>
</ng-template>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -1,183 +0,0 @@
canvas {
position: fixed;
left: 0;
top: 0;
z-index: -1;
}
h1 {
font-variant: all-small-caps;
margin: unset;
}
ion-content {
position: absolute;
z-index: 0;
--background: transparent;
}
ion-grid {
max-width: 760px;
}
.inline-container {
display: flex;
justify-content: center;
align-items: center;
}
.card-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1rem;
}
ion-card {
padding: 2.4rem;
h1 {
color: var(--ion-color-success);
padding-left: 0.5rem;
}
ion-icon {
font-size: 40px;
}
li {
margin-bottom: 2rem;
}
// download info card
ion-card {
min-height: 260px;
width: 80%;
background: #615F5F;
color: var(--ion-text-color);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
border-radius: 44px;
text-align: left;
cursor: pointer;
position: relative;
padding: 1rem 2rem;
transition: all 350ms ease;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transition-property: transform;
transform: scale(1.05);
transition-delay: 40ms;
}
ion-card-title {
color: var(--ion-text-color);
font-size: 1.3rem;
}
ion-footer {
position: absolute;
bottom: 10px;
left: 0;
color: var(--ion-text-color);
p {
font-size: 1.1rem;
font-weight: bold;
margin: unset;
}
ion-icon {
font-size: 1.6rem;
}
}
.footer-md::before {
background-image: none;
}
}
.login-button {
--background: var(--color-accent);
--padding-bottom: 2.5rem;
--padding-top: 2.5rem;
--padding-start: 2.5rem;
--padding-end: 2.5rem;
--border-radius: 44px;
font-size: 1.4rem !important;
font-weight: bold;
text-transform: none;
letter-spacing: normal;
transition: all 350ms ease;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transition-property: transform;
transform: scale(1.05);
transition-delay: 40ms;
}
ion-icon {
font-size: 1.7rem;
}
}
.launch-button {
--background: var(--alt-blue);
}
#information:after, #launch:after {
content: '';
position: absolute;
left: 0;
top: 79%;
width: 100%;
height: 100%;
background: var(--color-accent);
}
#launch:after {
background: var(--alt-blue);
}
}
.mb-12 {
margin-bottom: 3rem;
}
.pb-2 {
padding-bottom: 0.5rem;
}
.pt-1 {
padding-top: 0.25rem;
}
.action-text {
font-variant-caps: all-small-caps;
padding-right: 0.5rem;
font-size: 1.5rem !important;
letter-spacing: 0.03rem;
padding-bottom: 0.1rem;
}
@media (max-width: 700px) {
.setup {
flex-direction: column;
}
ion-card {
ion-card {
width: 100%;
padding-bottom: unset;
}
#information:after {
top: 84%;
}
#launch:after {
top: 85%;
}
}
}

View File

@@ -1,143 +0,0 @@
import { DOCUMENT } from '@angular/common'
import { Component, ElementRef, Inject, NgZone, ViewChild } from '@angular/core'
import { DownloadHTMLService, ErrorService } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/api.service'
import { StateService } from 'src/app/services/state.service'
@Component({
selector: 'success',
templateUrl: 'success.page.html',
styleUrls: ['success.page.scss'],
})
export class SuccessPage {
@ViewChild('canvas', { static: true })
private canvas: ElementRef<HTMLCanvasElement> =
{} as ElementRef<HTMLCanvasElement>
private ctx: CanvasRenderingContext2D = {} as CanvasRenderingContext2D
torAddress?: string
lanAddress?: string
cert?: string
tileSize = 16
// a higher fade factor will make the characters fade quicker
fadeFactor = 0.07
columns: any[] = []
maxStackHeight: any
disableLogin = this.stateService.setupType === 'fresh'
constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly errorService: ErrorService,
private readonly stateService: StateService,
private readonly api: ApiService,
private readonly downloadHtml: DownloadHTMLService,
private readonly ngZone: NgZone,
) {}
get setupType() {
return this.stateService.setupType
}
get isKiosk() {
return ['localhost', '127.0.0.1'].includes(this.document.location.hostname)
}
async ngAfterViewInit() {
this.ngZone.runOutsideAngular(() => this.initMatrix())
setTimeout(() => this.complete(), 1000)
}
download() {
const torAddress = this.document.getElementById('tor-addr')
const lanAddress = this.document.getElementById('lan-addr')
if (torAddress) torAddress.innerHTML = this.torAddress!
if (lanAddress) lanAddress.innerHTML = this.lanAddress!
this.document
.getElementById('cert')
?.setAttribute(
'href',
'data:application/x-x509-ca-cert;base64,' +
encodeURIComponent(this.cert!),
)
let html = this.document.getElementById('downloadable')?.innerHTML || ''
this.downloadHtml.download('StartOS-info.html', html).then(_ => {
this.disableLogin = false
})
}
exitKiosk() {
this.api.exit()
}
private async complete() {
try {
const ret = await this.api.complete()
if (!this.isKiosk) {
this.torAddress = ret['tor-address'].replace(/^https:/, 'http:')
this.lanAddress = ret['lan-address'].replace(/^https:/, 'http:')
this.cert = ret['root-ca']
await this.api.exit()
}
} catch (e: any) {
this.errorService.handleError(e)
}
}
private initMatrix() {
this.ctx = this.canvas.nativeElement.getContext('2d')!
this.canvas.nativeElement.width = window.innerWidth
this.canvas.nativeElement.height = window.innerHeight
this.setupMatrixGrid()
this.tick()
}
private setupMatrixGrid() {
this.maxStackHeight = Math.ceil(this.ctx.canvas.height / this.tileSize)
// divide the canvas into columns
for (let i = 0; i < this.ctx.canvas.width / this.tileSize; ++i) {
const column = {} as any
// save the x position of the column
column.x = i * this.tileSize
// create a random stack height for the column
column.stackHeight = 10 + Math.random() * this.maxStackHeight
// add a counter to count the stack height
column.stackCounter = 0
// add the column to the list
this.columns.push(column)
}
}
private draw() {
// draw a semi transparent black rectangle on top of the scene to slowly fade older characters
this.ctx.fillStyle = 'rgba( 0 , 0 , 0 , ' + this.fadeFactor + ' )'
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
// pick a font slightly smaller than the tile size
this.ctx.font = this.tileSize - 2 + 'px monospace'
this.ctx.fillStyle = '#ff4961'
for (let i = 0; i < this.columns.length; ++i) {
// pick a random ascii character (change the 94 to a higher number to include more characters)
const randomCharacter = String.fromCharCode(
33 + Math.floor(Math.random() * 94),
)
this.ctx.fillText(
randomCharacter,
this.columns[i].x,
this.columns[i].stackCounter * this.tileSize + this.tileSize,
)
// if the stack is at its height limit, pick a new random height and reset the counter
if (++this.columns[i].stackCounter >= this.columns[i].stackHeight) {
this.columns[i].stackHeight = 10 + Math.random() * this.maxStackHeight
this.columns[i].stackCounter = 0
}
}
}
private tick() {
this.draw()
setTimeout(this.tick.bind(this), 50)
}
}

View File

@@ -0,0 +1,105 @@
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import {
DiskInfo,
DriveComponent,
ErrorService,
toGuid,
} from '@start9labs/shared'
import {
TuiDialogOptions,
TuiDialogService,
TuiLoaderModule,
} from '@taiga-ui/core'
import {
TuiButtonModule,
TuiCardModule,
TuiCellModule,
} from '@taiga-ui/experimental'
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
@Component({
standalone: true,
template: `
<section tuiCardLarge>
<header>Transfer</header>
Select the physical drive containing your StartOS data
@if (loading) {
<tui-loader />
}
@for (drive of drives; track drive) {
<button tuiCell [drive]="drive" (click)="select(drive)"></button>
}
<button tuiButton iconLeft="tuiIconRotateCwLarge" (click)="refresh()">
Refresh
</button>
</section>
`,
imports: [
TuiCardModule,
TuiCellModule,
TuiButtonModule,
TuiLoaderModule,
DriveComponent,
],
})
export default class TransferPage {
private readonly apiService = inject(ApiService)
private readonly router = inject(Router)
private readonly dialogs = inject(TuiDialogService)
private readonly errorService = inject(ErrorService)
private readonly stateService = inject(StateService)
loading = true
drives: DiskInfo[] = []
async ngOnInit() {
this.stateService.setupType = 'transfer'
await this.getDrives()
}
async refresh() {
await this.getDrives()
}
async getDrives() {
this.loading = true
try {
this.drives = await this.apiService
.getDrives()
.then(drives => drives.filter(toGuid))
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
select(drive: DiskInfo) {
this.dialogs
.open(TUI_PROMPT, OPTIONS)
.pipe(filter(Boolean))
.subscribe(() => {
this.stateService.recoverySource = {
type: 'migrate',
guid: toGuid(drive) || '',
}
this.router.navigate([`storage`])
})
}
}
const OPTIONS: Partial<TuiDialogOptions<TuiPromptData>> = {
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,16 +0,0 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { TransferPage } from './transfer.page'
const routes: Routes = [
{
path: '',
component: TransferPage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class TransferPageRoutingModule {}

View File

@@ -1,21 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import {
GuidPipePipesModule,
UnitConversionPipesModule,
} from '@start9labs/shared'
import { TransferPage } from './transfer.page'
import { TransferPageRoutingModule } from './transfer-routing.module'
@NgModule({
declarations: [TransferPage],
imports: [
CommonModule,
IonicModule,
TransferPageRoutingModule,
UnitConversionPipesModule,
GuidPipePipesModule,
],
})
export class TransferPageModule {}

View File

@@ -1,61 +0,0 @@
<ion-content>
<ion-grid>
<ion-row class="ion-align-items-center">
<ion-col>
<ion-card color="dark">
<ion-card-header class="ion-text-center">
<ion-card-title>Transfer</ion-card-title>
<div class="center-wrapper">
<ion-card-subtitle>
Select the physical drive containing your StartOS data
</ion-card-subtitle>
</div>
</ion-card-header>
<ion-card-content class="ion-margin">
<ion-spinner
*ngIf="loading"
class="center-spinner"
name="lines"
></ion-spinner>
<!-- loaded -->
<ion-item-group *ngIf="!loading" class="ion-text-center">
<!-- drives -->
<ng-container *ngFor="let drive of drives">
<ion-item
*ngIf="drive | guid as guid"
button
(click)="select(guid)"
lines="none"
>
<ion-icon
slot="start"
name="save-outline"
size="large"
></ion-icon>
<ion-label>
<h1>{{ drive.logicalname }}</h1>
<p>
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model ||
'Unknown Model' }}
</p>
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
</ion-label>
</ion-item>
</ng-container>
<ion-button
class="ion-margin-top"
color="primary"
(click)="refresh()"
>
<ion-icon slot="start" name="refresh"></ion-icon>
Refresh
</ion-button>
</ion-item-group>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -1,68 +0,0 @@
import { Component } from '@angular/core'
import { NavController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/api.service'
import { DiskInfo, ErrorService } from '@start9labs/shared'
import { StateService } from 'src/app/services/state.service'
import { TuiDialogService } from '@taiga-ui/core'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { filter } from 'rxjs'
@Component({
selector: 'app-transfer',
templateUrl: 'transfer.page.html',
styleUrls: ['transfer.page.scss'],
})
export class TransferPage {
loading = true
drives: DiskInfo[] = []
constructor(
private readonly apiService: ApiService,
private readonly navCtrl: NavController,
private readonly dialogs: TuiDialogService,
private readonly errorService: ErrorService,
private readonly stateService: StateService,
) {}
async ngOnInit() {
this.stateService.setupType = 'transfer'
await this.getDrives()
}
async refresh() {
this.loading = true
await this.getDrives()
}
async getDrives() {
try {
this.drives = await this.apiService.getDrives()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
select(guid: string) {
this.dialogs
.open(TUI_PROMPT, {
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',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
this.stateService.recoverySource = {
type: 'migrate',
guid,
}
this.navCtrl.navigateForward(`/storage`)
})
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core'
import { inject, Injectable } from '@angular/core'
import {
DiskListResponse,
StartOSDiskInfo,
@@ -28,9 +28,7 @@ import { Observable } from 'rxjs'
providedIn: 'root',
})
export class LiveApiService extends ApiService {
constructor(private readonly http: HttpService) {
super()
}
private readonly http = inject(HttpService)
async getSetupStatus() {
return this.rpcRequest<SetupStatus | null>({

View File

@@ -111,7 +111,7 @@ export class MockApiService extends ApiService {
guid: 'guid-guid-guid-guid',
},
],
capacity: 1000190509056,
capacity: 1000190509,
guid: null,
},
]

View File

@@ -1,17 +1,16 @@
import { Injectable } from '@angular/core'
import { ApiService, RecoverySource } from './api/api.service'
import { inject, Injectable } from '@angular/core'
import { ApiService, RecoverySource } from './api.service'
@Injectable({
providedIn: 'root',
})
export class StateService {
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
private readonly api = inject(ApiService)
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
recoverySource?: RecoverySource
recoveryPassword?: string
constructor(private readonly api: ApiService) {}
async importDrive(guid: string, password: string): Promise<void> {
await this.api.attach({
guid,

View File

@@ -1,345 +1,69 @@
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: normal;
src: url('/assets/fonts/Montserrat/Montserrat-Regular.ttf');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: bold;
src: url('/assets/fonts/Montserrat/Montserrat-Bold.ttf');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 600;
src: url('/assets/fonts/Montserrat/Montserrat-SemiBold.ttf');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: bold;
src: url('/assets/fonts/Montserrat/Montserrat-Bold.ttf');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 500;
src: url('/assets/fonts/Montserrat/Montserrat-Medium.ttf');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: thin;
src: url('/assets/fonts/Montserrat/Montserrat-Light.ttf');
}
@font-face {
font-family: 'Benton Sans';
font-style: normal;
font-weight: normal;
src: url('/assets/fonts/Benton_Sans/BentonSans-Regular.otf');
}
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: normal;
src: url('/assets/fonts/Open_Sans/OpenSans-Regular.ttf');
}
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: bold;
src: url('/assets/fonts/Open_Sans/OpenSans-Bold.ttf');
}
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
src: url('/assets/fonts/Open_Sans/OpenSans-Bold.ttf');
}
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
src: url('/assets/fonts/Open_Sans/OpenSans-SemiBold.ttf');
}
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: thin;
src: url('/assets/fonts/Open_Sans/OpenSans-Light.ttf');
}
/** Ionic CSS Variables overrides **/
:root {
--ion-font-family: 'Montserrat', sans-serif;
--ion-background-color: #333333;
--ion-background-color-rgb: 51, 51, 51;
--ion-text-color: #F4F4F5;
--ion-text-color-rgb: 244, 244, 245;
--ion-color-step-50: #3d3d3d;
--ion-color-step-100: #464646;
--ion-color-step-150: #505050;
--ion-color-step-200: #5a5a5a;
--ion-color-step-250: #636364;
--ion-color-step-300: #6d6d6d;
--ion-color-step-350: #777777;
--ion-color-step-400: #808081;
--ion-color-step-450: #8a8a8a;
--ion-color-step-500: #949494;
--ion-color-step-550: #9d9d9e;
--ion-color-step-600: #a7a7a7;
--ion-color-step-650: #b0b0b1;
--ion-color-step-700: #bababb;
--ion-color-step-750: #c4c4c5;
--ion-color-step-800: #cdcdce;
--ion-color-step-850: #d7d7d8;
--ion-color-step-900: #e1e1e2;
--ion-color-step-950: #eaeaeb;
--ion-color-dark: var(--ion-color-step-50) !important;
// --ion-color-base-rgb:
--ion-color-dark-contrast: var(--ion-color-step-950) !important;
// --ion-color-dark-contrast-rgb:
--ion-color-dark-shade: var(--ion-color-step-100) !important;
--ion-color-dark-tint: var(--ion-color-step-250) !important;
--color-accent: #6866cc;
--color-dark-black: #121212;
--alt-red: #FF4961;
--alt-orange: #F89248;
--alt-yellow: #E5D53E;
--alt-green: #3DCF6F;
--alt-blue: #00A8A8;
--alt-purple: #9747FF;
}
h1,
h2,
h3,
h4 {
font-weight: 400;
}
h1 {
font-size: 42px;
}
ion-card-title {
margin: 16px 0;
font-family: 'Montserrat', sans-serif;
font-size: x-large;
--color: var(--ion-color-light);
}
ion-card-subtitle {
font-size: 20px;
font-weight: 200;
max-width: 400px;
padding: 0.7rem;
color: var(--ion-color-step-900) !important;
}
ion-label ion-text {
font-size: 1.2rem;
font-weight: 500;
}
p {
color: var(--ion-color-dark-contrast) !important;
font-size: 1.12rem !important;
font-family: 'Open Sans', sans-serif;
font-weight: normal;
}
ion-icon {
color: var(--ion-color-dark-contrast) !important;
}
.small-caps {
font-variant-caps: all-small-caps;
}
ion-grid {
padding-top: 32px;
html,
body {
height: 100%;
max-width: 695px;
}
ion-row {
height: 90%;
app-root {
display: block;
height: 100%;
}
ion-card {
border-radius: 31px;
tui-root {
height: 100%;
}
ion-item {
--highlight-color-valid: transparent;
--highlight-color-invalid: transparent;
}
ion-avatar {
width: 27px;
height: 27px;
}
ion-toast {
--background: var(--ion-color-light);
--button-color: var(--ion-color-dark);
--border-style: solid;
--border-width: 1px;
--color: white;
}
.center-spinner {
height: 6vh;
width: 100%;
}
.inline {
* {
display: inline-block;
vertical-align: middle;
padding-left: 0px 0.3rem;
}
}
.claim-button {
margin-inline-start: 0;
margin-inline-end: 0;
margin-top: 24px;
min-width: 140px;
}
.error-toast {
--border-color: var(--ion-color-danger);
width: 40%;
min-width: 400px;
--end: 8px;
right: 8px;
left: unset;
top: 64px;
}
.error-border {
border: 2px solid var(--ion-color-danger);
border-radius: 4px;
}
.success-border {
border: 2px solid var(--ion-color-success);
border-radius: 4px;
}
.sc-ion-label-md-s p {
line-height: 23px;
}
ion-button {
--padding-top: 1.3rem;
--padding-bottom: 1.3rem;
}
ion-item {
border: var(--ion-color-step-750) 1px solid;
margin: 2rem;
--background: transparent;
--border-color: var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, 0.13))));
transition: all 350ms ease;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transition-property: transform;
transform: scale(1.05);
transition-delay: 40ms;
}
ion-button {
--padding-top: unset;
--padding-bottom: unset;
}
}
.item.sc-ion-label-md-h,
.item .sc-ion-label-md-h {
white-space: normal;
}
.center-wrapper {
router-outlet + * {
height: 100%;
max-width: min(35rem, 100vw);
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
height: 100%;
}
box-sizing: border-box;
padding: 2rem;
margin: 0 auto;
.loader {
--spinner-color: var(--ion-color-tertiary) !important;
}
.toolbar-background {
background: #2a2a2a !important;
}
.toolbar-container {
padding-right: 2rem !important;
}
ion-header {
ion-toolbar {
--border-color: var(--ion-color-step-950);
--border-width: 0 0 1px 0;
--min-height: 80px;
--padding-top: 20px;
--padding-bottom: 20px;
--padding-end: 2rem;
[tuiCardLarge] {
width: 100%;
background: var(--tui-base-02);
margin: auto;
}
}
ion-footer {
ion-toolbar {
--border-width: 0;
--padding-end: 2.3rem;
--padding-bottom: 2rem;
button:disabled {
opacity: var(--tui-disabled-opacity);
pointer-events: none;
}
header {
position: relative;
display: flex;
flex-direction: column;
text-align: center;
font: var(--tui-font-heading-4);
p {
font: var(--tui-font-text-m);
color: var(--tui-text-02);
}
}
.footer-md::before {
content: none;
h2 {
margin: 0;
font: var(--tui-font-heading-6);
}
@media (max-width: 500px) {
h1 {
font-size: 36px;
}
ion-item {
margin: 0 0.5rem 2rem 0.5rem;
}
.g-success {
color: var(--tui-success-fill);
}
p a {
color: var(--ion-text-color);
// text-decoration: none;
font-weight: 600;
text-underline-offset: 0.4rem;
.g-warning {
color: var(--tui-warning-fill);
}
.g-error {
color: var(--tui-error-fill);
}
.g-info {
color: var(--tui-info-fill);
}

View File

@@ -5,12 +5,12 @@
"@angular/common": "^17.0.6",
"@angular/core": "^17.0.6",
"@angular/router": "^17.0.6",
"@ionic/angular": ">=6.0.0",
"@ng-web-apis/mutation-observer": ">=2.0.0",
"@ng-web-apis/resize-observer": ">=2.0.0",
"@start9labs/emver": "^0.1.5",
"@taiga-ui/cdk": ">=3.0.0",
"@taiga-ui/core": ">=3.0.0",
"@taiga-ui/experimental": ">=3.0.0",
"@tinkoff/ng-dompurify": ">=4.0.0",
"ansi-to-html": "^0.7.2"
},

View File

@@ -0,0 +1,29 @@
import { Component, Input } from '@angular/core'
import { TuiIconModule, TuiTitleModule } from '@taiga-ui/experimental'
import { UnitConversionPipesModule } from '../pipes/unit-conversion/unit-conversion.module'
@Component({
standalone: true,
selector: 'button[drive]',
template: `
<tui-icon icon="tuiIconSave" />
<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: [TuiIconModule, TuiTitleModule, UnitConversionPipesModule],
})
export class DriveComponent {
@Input() drive!: {
logicalname: string | null
vendor: string | null
model: string | null
capacity: number
}
}

View File

@@ -1,15 +0,0 @@
<div *tuiLet="progress$ | async as progress" class="card">
<h1 class="title">Initializing StartOS</h1>
<div *ngIf="progress" class="center-wrapper">
Progress: {{ (progress * 100).toFixed(0) }}%
</div>
<progress
tuiProgressBar
class="progress"
[attr.value]="progress && progress < 1 ? progress : null"
></progress>
<p>{{ getMessage(progress) }}</p>
</div>
<logs-window class="logs" />

View File

@@ -1,33 +0,0 @@
.card {
border-radius: 0.25rem;
padding: 1rem;
margin: 1.5rem;
text-align: center;
// TODO: Theme
background: #e0e0e0;
color: #333;
--tui-clear-inverse: rgba(0, 0, 0, 0.1);
}
.title {
font-size: 2.5rem;
margin: 1rem;
}
.progress {
max-width: 40rem;
margin: 1rem auto;
}
.logs {
display: flex;
flex-direction: column;
height: 18rem;
padding: 1rem;
margin: 0 1.5rem auto;
text-align: left;
overflow: hidden;
border-radius: 2rem;
// TODO: Theme
background: #181818;
}

View File

@@ -1,11 +1,67 @@
import { Component, inject, Input, Output } from '@angular/core'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
Output,
} from '@angular/core'
import { TuiLetModule } from '@taiga-ui/cdk'
import { TuiProgressModule } from '@taiga-ui/kit'
import { delay, filter } from 'rxjs'
import { LogsWindowComponent } from './logs-window.component'
import { SetupService } from '../../services/setup.service'
@Component({
standalone: true,
selector: 'app-initializing',
templateUrl: 'initializing.component.html',
styleUrls: ['initializing.component.scss'],
template: `
<section *tuiLet="progress$ | async as progress">
<h1 [style.font-size.rem]="2.5" [style.margin.rem]="1">
Initializing StartOS
</h1>
<div *ngIf="progress" class="center-wrapper">
Progress: {{ (progress * 100).toFixed(0) }}%
</div>
<progress
tuiProgressBar
class="progress"
[style.max-width.rem]="40"
[style.margin]="'1rem auto'"
[attr.value]="progress && progress < 1 ? progress : null"
></progress>
<p>{{ getMessage(progress) }}</p>
</section>
<logs-window />
`,
styles: `
section {
border-radius: 0.25rem;
padding: 1rem;
margin: 1.5rem;
text-align: center;
// TODO: Theme
background: #e0e0e0;
color: #333;
--tui-clear-inverse: rgba(0, 0, 0, 0.1);
}
logs-window {
display: flex;
flex-direction: column;
height: 18rem;
padding: 1rem;
margin: 0 1.5rem auto;
text-align: left;
overflow: hidden;
border-radius: 2rem;
// TODO: Theme
background: #181818;
}
`,
imports: [CommonModule, LogsWindowComponent, TuiLetModule, TuiProgressModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InitializingComponent {
readonly progress$ = inject(SetupService)

View File

@@ -1,14 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { TuiLetModule } from '@taiga-ui/cdk'
import { TuiProgressModule } from '@taiga-ui/kit'
import { LogsWindowComponent } from './logs-window.component'
import { InitializingComponent } from './initializing.component'
@NgModule({
imports: [CommonModule, TuiLetModule, LogsWindowComponent, TuiProgressModule],
declarations: [InitializingComponent],
exports: [InitializingComponent],
})
export class InitializingModule {}

View File

@@ -1,8 +0,0 @@
<ion-grid class="full-height">
<ion-row class="ion-align-items-center ion-text-center full-height">
<ion-col>
<ion-spinner color="tertiary"></ion-spinner>
<p>{{ text }}</p>
</ion-col>
</ion-row>
</ion-grid>

View File

@@ -1,11 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { TextSpinnerComponent } from './text-spinner.component'
@NgModule({
declarations: [TextSpinnerComponent],
imports: [CommonModule, IonicModule],
exports: [TextSpinnerComponent],
})
export class TextSpinnerComponentModule {}

View File

@@ -1,3 +0,0 @@
.full-height {
height: 100%;
}

View File

@@ -1,10 +0,0 @@
import { Component, Input } from '@angular/core'
@Component({
selector: 'text-spinner',
templateUrl: './text-spinner.component.html',
styleUrls: ['./text-spinner.component.scss'],
})
export class TextSpinnerComponent {
@Input() text = ''
}

View File

@@ -1,121 +0,0 @@
import {
Directive,
OnInit,
Optional,
ElementRef,
Inject,
InjectionToken,
Input,
NgZone,
} from '@angular/core'
import { ResizeObserverService } from '@ng-web-apis/resize-observer'
import { distinctUntilChanged, map, Observable } from 'rxjs'
import { tuiZonefree, TuiDestroyService } from '@taiga-ui/cdk'
import { IonCol } from '@ionic/angular'
import { takeUntil } from 'rxjs'
export type Step = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
const SIZE: readonly Step[] = ['xl', 'lg', 'md', 'sm', 'xs']
/**
* Not exported:
* https://github.com/ionic-team/ionic-framework/blob/main/core/src/utils/media.ts
*
* export const SIZE_TO_MEDIA: any = {
* xs: '(min-width: 0px)',
* sm: '(min-width: 576px)',
* md: '(min-width: 768px)',
* lg: '(min-width: 992px)',
* xl: '(min-width: 1200px)',
* };
*/
export const BREAKPOINTS = new InjectionToken<readonly [number, Step][]>(
'BREAKPOINTS',
{
factory: () => [
[1200, 'xl'],
[992, 'lg'],
[768, 'md'],
[576, 'sm'],
[0, 'xs'],
],
},
)
@Directive({
selector: '[responsiveColViewport]',
exportAs: 'viewport',
providers: [ResizeObserverService],
standalone: true,
})
export class ResponsiveColViewportDirective extends Observable<Step> {
@Input()
responsiveColViewport: Observable<Step> | '' = ''
constructor(
@Inject(BREAKPOINTS)
private readonly breakpoints: readonly [number, Step][],
private readonly resize$: ResizeObserverService,
private readonly elementRef: ElementRef<HTMLElement>,
private readonly zone: NgZone,
) {
super(subscriber =>
(this.responsiveColViewport || this.stream$).subscribe(subscriber),
)
}
private readonly stream$ = this.resize$.pipe(
map(() => this.elementRef.nativeElement.clientWidth),
map(width => this.breakpoints.find(([step]) => width >= step)?.[1] || 'xs'),
distinctUntilChanged(),
tuiZonefree(this.zone),
)
}
@Directive({
selector: 'ion-col[responsiveCol]',
providers: [TuiDestroyService],
standalone: true,
})
export class ResponsiveColDirective implements OnInit {
readonly size: Record<Step, string | undefined> = {
xs: '12',
sm: '6',
md: '4',
lg: '3',
xl: '2',
}
constructor(
@Optional()
viewport$: ResponsiveColViewportDirective | null,
destroy$: TuiDestroyService,
private readonly col: IonCol,
) {
viewport$?.pipe(takeUntil(destroy$)).subscribe(size => {
const max = this.size[size] || this.findMax(size)
this.col.sizeLg = max
this.col.sizeMd = max
this.col.sizeSm = max
this.col.sizeXl = max
this.col.sizeXs = max
})
}
ngOnInit() {
this.size.lg = this.col.sizeLg
this.size.md = this.col.sizeMd
this.size.sm = this.col.sizeSm
this.size.xl = this.col.sizeXl
this.size.xs = this.col.sizeXs
}
private findMax(current: Step): string | undefined {
const start = SIZE.indexOf(current) - 1
const max = SIZE.find((size, i) => i > start && this.size[size]) || current
return this.size[max]
}
}

View File

@@ -1,8 +0,0 @@
import { NgModule } from '@angular/core'
import { GuidPipe } from './guid.pipe'
@NgModule({
declarations: [GuidPipe],
exports: [GuidPipe],
})
export class GuidPipePipesModule {}

View File

@@ -1,11 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
import { DiskInfo } from '../../types/api'
@Pipe({
name: 'guid',
})
export class GuidPipe implements PipeTransform {
transform(disk: DiskInfo): string | null {
return disk.guid || disk.partitions.find(p => p.guid)?.guid || null
}
}

View File

@@ -6,20 +6,17 @@ export * from './classes/http-error'
export * from './classes/rpc-error'
export * from './components/initializing/logs-window.component'
export * from './components/initializing/initializing.module'
export * from './components/initializing/initializing.component'
export * from './components/loading/loading.component'
export * from './components/loading/loading.module'
export * from './components/loading/loading.service'
export * from './components/markdown/markdown.component'
export * from './components/markdown/markdown.component.module'
export * from './components/text-spinner/text-spinner.component'
export * from './components/text-spinner/text-spinner.component.module'
export * from './components/ticker/ticker.component'
export * from './components/ticker/ticker.module'
export * from './components/drive.component'
export * from './directives/drag-scroller.directive'
export * from './directives/responsive-col.directive'
export * from './directives/safe-links.directive'
export * from './directives/enter/enter.directive'
export * from './directives/enter/enter.module'
@@ -28,8 +25,6 @@ export * from './mocks/get-setup-status'
export * from './pipes/emver/emver.module'
export * from './pipes/emver/emver.pipe'
export * from './pipes/guid/guid.module'
export * from './pipes/guid/guid.pipe'
export * from './pipes/markdown/markdown.module'
export * from './pipes/markdown/markdown.pipe'
export * from './pipes/shared/shared.module'
@@ -70,5 +65,6 @@ export * from './util/get-pkg-id'
export * from './util/invert'
export * from './util/misc.util'
export * from './util/rpc.util'
export * from './util/to-guid'
export * from './util/to-local-iso-string'
export * from './util/unused'

View File

@@ -0,0 +1,5 @@
import { DiskInfo } from '../types/api'
export function toGuid(disk: DiskInfo | null): string | null {
return disk?.guid || disk?.partitions.find(p => p.guid)?.guid || null
}

Some files were not shown because too many files have changed in this diff Show More