mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 04:01:58 +00:00
feat: move all frontend projects under the same Angular workspace (#1141)
* feat: move all frontend projects under the same Angular workspace * Refactor/angular workspace (#1154) * update frontend build steps Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> Co-authored-by: Lucy Cifferello <12953208+elvece@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
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)
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
loadChildren: () => import('./pages/logs/logs.module').then( m => m.LogsPageModule)
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {
|
||||
scrollPositionRestoration: 'enabled',
|
||||
preloadingStrategy: PreloadAllModules,
|
||||
useHash: true,
|
||||
})
|
||||
],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
@@ -0,0 +1,3 @@
|
||||
<ion-app>
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
</ion-app>
|
||||
10
frontend/projects/diagnostic-ui/src/app/app.component.ts
Normal file
10
frontend/projects/diagnostic-ui/src/app/app.component.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
})
|
||||
export class AppComponent {
|
||||
constructor() {}
|
||||
}
|
||||
45
frontend/projects/diagnostic-ui/src/app/app.module.ts
Normal file
45
frontend/projects/diagnostic-ui/src/app/app.module.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ErrorHandler, NgModule } from '@angular/core'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
import { RouteReuseStrategy } from '@angular/router'
|
||||
import { IonicModule, IonicRouteStrategy } from '@ionic/angular'
|
||||
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 { HttpService } from './services/http.service'
|
||||
import { GlobalErrorHandler } from './services/global-error-handler.service'
|
||||
import { WorkspaceConfig } from '@shared'
|
||||
|
||||
const { useMocks } = require('../../../../config.json') as WorkspaceConfig
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
entryComponents: [],
|
||||
imports: [
|
||||
HttpClientModule,
|
||||
BrowserModule,
|
||||
IonicModule.forRoot({
|
||||
mode: 'md',
|
||||
}),
|
||||
AppRoutingModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||
{
|
||||
provide: ApiService,
|
||||
useFactory: (http: HttpService) => {
|
||||
if (useMocks) {
|
||||
return new MockApiService()
|
||||
} else {
|
||||
return new LiveApiService(http)
|
||||
}
|
||||
},
|
||||
deps: [HttpService],
|
||||
},
|
||||
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
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 {}
|
||||
@@ -0,0 +1,18 @@
|
||||
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 { HomePageRoutingModule } from './home-routing.module'
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
HomePageRoutingModule
|
||||
],
|
||||
declarations: [HomePage]
|
||||
})
|
||||
export class HomePageModule {}
|
||||
@@ -0,0 +1,34 @@
|
||||
<ion-content>
|
||||
<div style="padding: 48px">
|
||||
<ng-container *ngIf="!restarted; else refresh">
|
||||
<h1 class="ion-text-center" style="padding-bottom: 36px; font-size: calc(2vw + 14px);">EmbassyOS - Diagnostic Mode</h1>
|
||||
<h2 style="padding-bottom: 16px; font-size: calc(1vw + 14px); font-weight: bold;">EmbassyOS launch error:</h2>
|
||||
<div class="code-block">
|
||||
<code><ion-text color="warning">{{ error.problem }}</ion-text></code>
|
||||
</div>
|
||||
<ion-button routerLink="logs">
|
||||
View Logs
|
||||
</ion-button>
|
||||
<h2 style="padding: 32px 0 16px 0; font-size: calc(1vw + 12px); font-weight: bold;">Possible solution:</h2>
|
||||
<div class="code-block">
|
||||
<code><ion-text color="success">{{ error.solution }}</ion-text></code>
|
||||
</div>
|
||||
<ion-button (click)="restart()">
|
||||
Restart Embassy
|
||||
</ion-button>
|
||||
<div *ngIf="error.code === 15 || error.code === 25" class="ion-padding-top">
|
||||
<ion-button *ngIf="error.code === 15" (click)="forgetDrive()">
|
||||
{{ error.code === 15 ? 'Setup Current Drive' : 'Enter Recovery Mode' }}
|
||||
</ion-button>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #refresh>
|
||||
<h1 class="ion-text-center" style="padding-bottom: 36px; font-size: calc(2vw + 12px);">Embassy is restarting</h1>
|
||||
<h2 style="padding-bottom: 16px; font-size: calc(1vw + 12px);">Wait for Embassy restart, then refresh this page or click REFRESH below.</h2>
|
||||
<ion-button (click)="refreshPage()">
|
||||
Refresh
|
||||
</ion-button>
|
||||
</ng-template>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,5 @@
|
||||
.code-block {
|
||||
background-color: rgb(69, 69, 69);
|
||||
padding: 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { LoadingController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
})
|
||||
export class HomePage {
|
||||
error: {
|
||||
code: number
|
||||
problem: string
|
||||
solution: string
|
||||
} = { } as any
|
||||
solutions: string[] = []
|
||||
restarted = false
|
||||
|
||||
constructor (
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly api: ApiService,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
try {
|
||||
const error = await this.api.getError()
|
||||
// incorrect drive
|
||||
if (error.code === 15) {
|
||||
this.error = {
|
||||
code: 15,
|
||||
problem: 'Unknown storage drive detected',
|
||||
solution: 'To use a different storage drive, replace the current one and click RESTART EMBASSY below. To use the current storage drive, click USE CURRENT DRIVE below, then follow instructions. No data will be erased during this process.'
|
||||
}
|
||||
// no drive
|
||||
} else if (error.code === 20) {
|
||||
this.error = {
|
||||
code: 20,
|
||||
problem: 'Storage drive not found',
|
||||
solution: 'Insert your EmbassyOS storage drive and click RESTART EMBASSY below.'
|
||||
}
|
||||
// drive corrupted
|
||||
} else if (error.code === 25) {
|
||||
this.error = {
|
||||
code: 25,
|
||||
problem: 'Storage drive corrupted. This could be the result of data corruption or a physical damage.',
|
||||
solution: 'It may or may not be possible to re-use this drive by reformatting and recovering from backup. To enter recovery mode, click ENTER RECOVERY MODE below, then follow instructions. No data will be erased during this step.'
|
||||
}
|
||||
} else {
|
||||
this.error = {
|
||||
code: error.code,
|
||||
problem: error.message,
|
||||
solution: 'Please conact support.'
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async restart (): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
async forgetDrive (): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.api.forgetDrive()
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
refreshPage (): void {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { LogsPage } from './logs.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LogsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
],
|
||||
declarations: [LogsPage],
|
||||
})
|
||||
export class LogsPageModule { }
|
||||
@@ -0,0 +1,47 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="/"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Logs</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content
|
||||
[scrollEvents]="true"
|
||||
(ionScroll)="scrollEvent()"
|
||||
style="height: 100%;"
|
||||
id="ion-content"
|
||||
class="ion-padding"
|
||||
>
|
||||
<ion-infinite-scroll id="scroller" *ngIf="!loading && needInfinite" position="top" threshold="0" (ionInfinite)="loadData($event)">
|
||||
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
|
||||
</ion-infinite-scroll>
|
||||
|
||||
<div id="container">
|
||||
<div id="template" style="white-space: pre-line;"></div>
|
||||
</div>
|
||||
<div id="button-div" *ngIf="!loading" style="width: 100%; text-align: center;">
|
||||
<ion-button *ngIf="!loadingMore" (click)="loadMore()" strong color="dark">
|
||||
Load More
|
||||
<ion-icon slot="end" name="refresh"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-spinner *ngIf="loadingMore" name="lines" color="warning"></ion-spinner>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="!loading"
|
||||
[ngStyle]="{
|
||||
'position': 'fixed',
|
||||
'bottom': '50px',
|
||||
'right': isOnBottom ? '-52px' : '30px',
|
||||
'border-radius': '100%',
|
||||
'transition': 'right 0.4s ease-out'
|
||||
}"
|
||||
>
|
||||
<ion-button style="width: 50px; height: 50px; --padding-start: 0px; --padding-end: 0px; --border-radius: 100%;" color="dark" (click)="scrollToBottom()" strong>
|
||||
<ion-icon name="chevron-down"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
</ion-content>
|
||||
113
frontend/projects/diagnostic-ui/src/app/pages/logs/logs.page.ts
Normal file
113
frontend/projects/diagnostic-ui/src/app/pages/logs/logs.page.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Component, ViewChild } from '@angular/core'
|
||||
import { IonContent } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
var Convert = require('ansi-to-html')
|
||||
var convert = new Convert({
|
||||
bg: 'transparent',
|
||||
})
|
||||
|
||||
@Component({
|
||||
selector: 'logs',
|
||||
templateUrl: './logs.page.html',
|
||||
styleUrls: ['./logs.page.scss'],
|
||||
})
|
||||
export class LogsPage {
|
||||
@ViewChild(IonContent) private content: IonContent
|
||||
loading = true
|
||||
loadingMore = false
|
||||
logs: string
|
||||
needInfinite = true
|
||||
startCursor: string
|
||||
endCursor: string
|
||||
limit = 200
|
||||
scrollToBottomButton = false
|
||||
isOnBottom = true
|
||||
|
||||
constructor (
|
||||
private readonly api: ApiService,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.getLogs()
|
||||
}
|
||||
|
||||
async getLogs () {
|
||||
try {
|
||||
// get logs
|
||||
const logs = await this.fetch()
|
||||
if (!logs.length) return
|
||||
|
||||
const container = document.getElementById('container')
|
||||
const beforeContainerHeight = container.scrollHeight
|
||||
const newLogs = document.getElementById('template').cloneNode(true) as HTMLElement
|
||||
newLogs.innerHTML = logs.map(l => `${l.timestamp} ${convert.toHtml(l.message)}`).join('\n') + (logs.length ? '\n' : '')
|
||||
|
||||
container.prepend(newLogs)
|
||||
const afterContainerHeight = container.scrollHeight
|
||||
|
||||
// scroll down
|
||||
scrollBy(0, afterContainerHeight - beforeContainerHeight)
|
||||
this.content.scrollToPoint(0, afterContainerHeight - beforeContainerHeight)
|
||||
|
||||
if (logs.length < this.limit) {
|
||||
this.needInfinite = false
|
||||
}
|
||||
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
async fetch (isBefore: boolean = true) {
|
||||
try {
|
||||
const cursor = isBefore ? this.startCursor : this.endCursor
|
||||
|
||||
const logsRes = await this.api.getLogs({
|
||||
cursor,
|
||||
before_flag: !!cursor ? isBefore : undefined,
|
||||
limit: this.limit,
|
||||
})
|
||||
|
||||
if ((isBefore || this.startCursor) && logsRes['start-cursor']) {
|
||||
this.startCursor = logsRes['start-cursor']
|
||||
}
|
||||
|
||||
if ((!isBefore || !this.endCursor) && logsRes['end-cursor']) {
|
||||
this.endCursor = logsRes['end-cursor']
|
||||
}
|
||||
this.loading = false
|
||||
|
||||
return logsRes.entries
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async loadMore () {
|
||||
try {
|
||||
this.loadingMore = true
|
||||
const logs = await this.fetch(false)
|
||||
if (!logs.length) return this.loadingMore = false
|
||||
|
||||
const container = document.getElementById('container')
|
||||
const newLogs = document.getElementById('template').cloneNode(true) as HTMLElement
|
||||
newLogs.innerHTML = logs.map(l => `${l.timestamp} ${convert.toHtml(l.message)}`).join('\n') + (logs.length ? '\n' : '')
|
||||
container.append(newLogs)
|
||||
this.loadingMore = false
|
||||
this.scrollEvent()
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
scrollEvent () {
|
||||
const buttonDiv = document.getElementById('button-div')
|
||||
this.isOnBottom = buttonDiv.getBoundingClientRect().top < window.innerHeight
|
||||
}
|
||||
|
||||
scrollToBottom () {
|
||||
this.content.scrollToBottom(500)
|
||||
}
|
||||
|
||||
async loadData (e: any): Promise<void> {
|
||||
await this.getLogs()
|
||||
e.target.complete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export abstract class ApiService {
|
||||
abstract getError (): Promise<GetErrorRes>
|
||||
abstract restart (): Promise<void>
|
||||
abstract forgetDrive (): Promise<void>
|
||||
abstract getLogs (params: GetLogsReq): Promise<GetLogsRes>
|
||||
}
|
||||
|
||||
export interface GetErrorRes {
|
||||
code: number,
|
||||
message: string,
|
||||
data: { details: string }
|
||||
}
|
||||
|
||||
export type GetLogsReq = { cursor?: string, before_flag?: boolean, limit?: number }
|
||||
export type GetLogsRes = LogsRes
|
||||
|
||||
export type LogsRes = { entries: Log[], 'start-cursor'?: string, 'end-cursor'?: string }
|
||||
|
||||
export interface Log {
|
||||
timestamp: string
|
||||
message: string
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from "@angular/core"
|
||||
import { HttpService } from "../http.service"
|
||||
import { ApiService, GetErrorRes, GetLogsReq, GetLogsRes } from "./api.service"
|
||||
|
||||
@Injectable()
|
||||
export class LiveApiService extends ApiService {
|
||||
|
||||
constructor (
|
||||
private readonly http: HttpService,
|
||||
) { super() }
|
||||
|
||||
getError (): Promise<GetErrorRes> {
|
||||
return this.http.rpcRequest<GetErrorRes>({
|
||||
method: 'diagnostic.error',
|
||||
params: { },
|
||||
})
|
||||
}
|
||||
|
||||
restart (): Promise<void> {
|
||||
return this.http.rpcRequest<void>({
|
||||
method: 'diagnostic.restart',
|
||||
params: { },
|
||||
})
|
||||
}
|
||||
|
||||
forgetDrive (): Promise<void> {
|
||||
return this.http.rpcRequest<void>({
|
||||
method: 'diagnostic.forget-disk',
|
||||
params: { },
|
||||
})
|
||||
}
|
||||
|
||||
getLogs (params: GetLogsReq): Promise<GetLogsRes> {
|
||||
return this.http.rpcRequest<GetLogsRes>({
|
||||
method: 'diagnostic.logs',
|
||||
params,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Injectable } from "@angular/core"
|
||||
import { pauseFor } from "../../util/misc.util"
|
||||
import { ApiService, GetErrorRes, GetLogsReq, GetLogsRes, Log } from "./api.service"
|
||||
|
||||
@Injectable()
|
||||
export class MockApiService extends ApiService {
|
||||
|
||||
constructor () { super() }
|
||||
|
||||
async getError (): Promise<GetErrorRes> {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
code: 15,
|
||||
message: 'Unknown Embassy',
|
||||
data: { details: 'Some details about the error here' }
|
||||
}
|
||||
}
|
||||
|
||||
async restart (): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
return null
|
||||
}
|
||||
|
||||
async forgetDrive (): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
return null
|
||||
}
|
||||
|
||||
async getLogs (params: GetLogsReq): Promise<GetLogsRes> {
|
||||
await pauseFor(1000)
|
||||
let entries: Log[]
|
||||
if (Math.random() < .2) {
|
||||
entries = packageLogs
|
||||
} else {
|
||||
const arrLength = params.limit ? Math.ceil(params.limit / packageLogs.length) : 10
|
||||
entries = new Array(arrLength).fill(packageLogs).reduce((acc, val) => acc.concat(val), [])
|
||||
}
|
||||
return {
|
||||
entries,
|
||||
'start-cursor': 'startCursor',
|
||||
'end-cursor': 'endCursor',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const packageLogs = [
|
||||
{
|
||||
timestamp: '2019-12-26T14:20:30.872Z',
|
||||
message: '****** START *****',
|
||||
},
|
||||
{
|
||||
timestamp: '2019-12-26T14:21:30.872Z',
|
||||
message: 'ServerLogs ServerLogs ServerLogs ServerLogs ServerLogs',
|
||||
},
|
||||
{
|
||||
timestamp: '2019-12-26T14:22:30.872Z',
|
||||
message: '****** FINISH *****',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ErrorHandler, Injectable } from '@angular/core'
|
||||
|
||||
@Injectable()
|
||||
export class GlobalErrorHandler implements ErrorHandler {
|
||||
|
||||
handleError (error: any): void {
|
||||
const chunkFailedMessage = /Loading chunk [\d]+ failed/
|
||||
|
||||
if (chunkFailedMessage.test(error.message)) {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class HttpService {
|
||||
|
||||
constructor (
|
||||
private readonly http: HttpClient,
|
||||
) { }
|
||||
|
||||
async rpcRequest<T> (options: RPCOptions): Promise<T> {
|
||||
const res = await this.httpRequest<RPCResponse<T>>(options)
|
||||
if (isRpcError(res)) throw new RpcError(res.error)
|
||||
if (isRpcSuccess(res)) return res.result
|
||||
}
|
||||
|
||||
async httpRequest<T> (body: RPCOptions): Promise<T> {
|
||||
const url = `${window.location.protocol}//${window.location.hostname}:${window.location.port}/rpc/v1`
|
||||
return this.http.post(url, body)
|
||||
.toPromise().then(a => a as T)
|
||||
.catch(e => { throw new HttpError(e) })
|
||||
}
|
||||
}
|
||||
|
||||
function RpcError (e: RPCError['error']): void {
|
||||
const { code, message, data } = e
|
||||
|
||||
this.code = code
|
||||
this.message = message
|
||||
|
||||
if (typeof data === 'string') {
|
||||
this.details = e.data
|
||||
this.revision = null
|
||||
} else {
|
||||
this.details = data.details
|
||||
}
|
||||
}
|
||||
|
||||
function HttpError (e: HttpErrorResponse): void {
|
||||
const { status, statusText } = e
|
||||
|
||||
this.code = status
|
||||
this.message = statusText
|
||||
this.details = null
|
||||
this.revision = null
|
||||
}
|
||||
|
||||
function isRpcError<Error, Result> (arg: { error: Error } | { result: Result}): arg is { error: Error } {
|
||||
return !!(arg as any).error
|
||||
}
|
||||
|
||||
function isRpcSuccess<Error, Result> (arg: { error: Error } | { result: Result}): arg is { result: Result } {
|
||||
return !!(arg as any).result
|
||||
}
|
||||
|
||||
export interface RPCOptions {
|
||||
method: string
|
||||
params: { [param: string]: Params }
|
||||
}
|
||||
|
||||
export interface RequestError {
|
||||
code: number
|
||||
message: string
|
||||
details: string
|
||||
}
|
||||
|
||||
export type Params = string | number | boolean | object | string[] | number[]
|
||||
|
||||
interface RPCBase {
|
||||
jsonrpc: '2.0'
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface RPCRequest<T> extends RPCBase {
|
||||
method: string
|
||||
params?: T
|
||||
}
|
||||
|
||||
export interface RPCSuccess<T> extends RPCBase {
|
||||
result: T
|
||||
}
|
||||
|
||||
export interface RPCError extends RPCBase {
|
||||
error: {
|
||||
code: number,
|
||||
message: string
|
||||
data?: {
|
||||
details: string
|
||||
} | string
|
||||
}
|
||||
}
|
||||
|
||||
export type RPCResponse<T> = RPCSuccess<T> | RPCError
|
||||
|
||||
type HttpError = HttpErrorResponse & { error: { code: string, message: string } }
|
||||
@@ -0,0 +1,3 @@
|
||||
export function pauseFor (ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const environment = {
|
||||
production: true
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// This file can be replaced during build by using the `fileReplacements` array.
|
||||
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
|
||||
// The list of file replacements can be found in `angular.json`.
|
||||
|
||||
export const environment = {
|
||||
production: false
|
||||
}
|
||||
|
||||
/*
|
||||
* For easier debugging in development mode, you can import the following file
|
||||
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
|
||||
*
|
||||
* This import should be commented out in production mode because it will have a negative impact
|
||||
* on performance if an error is thrown.
|
||||
*/
|
||||
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.
|
||||
22
frontend/projects/diagnostic-ui/src/index.html
Normal file
22
frontend/projects/diagnostic-ui/src/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>EmbassyOS Diagnostic UI</title>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
|
||||
<link rel="icon" type="image/png" href="assets/icon/favicon.ico" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
11
frontend/projects/diagnostic-ui/src/main.ts
Normal file
11
frontend/projects/diagnostic-ui/src/main.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { enableProdMode } from '@angular/core'
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
|
||||
import { AppModule } from './app/app.module'
|
||||
import { environment } from './environments/environment'
|
||||
|
||||
if (environment.production) {
|
||||
enableProdMode();
|
||||
}
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.log(err));
|
||||
65
frontend/projects/diagnostic-ui/src/polyfills.ts
Normal file
65
frontend/projects/diagnostic-ui/src/polyfills.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* This file includes polyfills needed by Angular and is loaded before the app.
|
||||
* You can add your own extra polyfills to this file.
|
||||
*
|
||||
* This file is divided into 2 sections:
|
||||
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
|
||||
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
|
||||
* file.
|
||||
*
|
||||
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
|
||||
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
|
||||
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
|
||||
*
|
||||
* Learn more in https://angular.io/guide/browser-support
|
||||
*/
|
||||
|
||||
/***************************************************************************************************
|
||||
* BROWSER POLYFILLS
|
||||
*/
|
||||
|
||||
/** IE11 requires the following for NgClass support on SVG elements */
|
||||
// import 'classlist.js'; // Run `npm install --save classlist.js`.
|
||||
|
||||
/**
|
||||
* Web Animations `@angular/platform-browser/animations`
|
||||
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
|
||||
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
|
||||
*/
|
||||
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
|
||||
|
||||
/**
|
||||
* By default, zone.js will patch all possible macroTask and DomEvents
|
||||
* user can disable parts of macroTask/DomEvents patch by setting following flags
|
||||
* because those flags need to be set before `zone.js` being loaded, and webpack
|
||||
* will put import in the top of bundle, so user need to create a separate file
|
||||
* in this directory (for example: zone-flags.ts), and put the following flags
|
||||
* into that file, and then add the following code before importing zone.js.
|
||||
* import './zone-flags';
|
||||
*
|
||||
* The flags allowed in zone-flags.ts are listed here.
|
||||
*
|
||||
* The following flags will work for all browsers.
|
||||
*
|
||||
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
|
||||
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
|
||||
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
|
||||
*
|
||||
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
|
||||
* with the following flag, it will bypass `zone.js` patch for IE/Edge
|
||||
*
|
||||
* (window as any).__Zone_enable_cross_context_check = true;
|
||||
*
|
||||
*/
|
||||
|
||||
import './zone-flags'
|
||||
|
||||
/***************************************************************************************************
|
||||
* Zone JS is required by default for Angular itself.
|
||||
*/
|
||||
import 'zone.js/dist/zone' // Included with Angular CLI.
|
||||
|
||||
|
||||
/***************************************************************************************************
|
||||
* APPLICATION IMPORTS
|
||||
*/
|
||||
41
frontend/projects/diagnostic-ui/src/styles.scss
Normal file
41
frontend/projects/diagnostic-ui/src/styles.scss
Normal file
@@ -0,0 +1,41 @@
|
||||
@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';
|
||||
|
||||
--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;
|
||||
}
|
||||
6
frontend/projects/diagnostic-ui/src/zone-flags.ts
Normal file
6
frontend/projects/diagnostic-ui/src/zone-flags.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Prevents Angular change detection from
|
||||
* running with certain Web Component callbacks
|
||||
*/
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
(window as any).__Zone_disable_customElements = true
|
||||
9
frontend/projects/diagnostic-ui/tsconfig.app.json
Normal file
9
frontend/projects/diagnostic-ui/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./"
|
||||
},
|
||||
"files": ["src/main.ts", "src/polyfills.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
49
frontend/projects/setup-wizard/src/app/app-routing.module.ts
Normal file
49
frontend/projects/setup-wizard/src/app/app-routing.module.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
|
||||
import { NavGuard, RecoveryNavGuard } from './guards/nav-guard'
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', redirectTo: '/product-key', pathMatch: 'full' },
|
||||
{
|
||||
path: 'init',
|
||||
loadChildren: () => import('./pages/init/init.module').then( m => m.InitPageModule),
|
||||
canActivate: [NavGuard],
|
||||
},
|
||||
{
|
||||
path: 'product-key',
|
||||
loadChildren: () => import('./pages/product-key/product-key.module').then( m => m.ProductKeyPageModule),
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () => import('./pages/home/home.module').then( m => m.HomePageModule),
|
||||
canActivate: [NavGuard],
|
||||
},
|
||||
{
|
||||
path: 'recover',
|
||||
loadChildren: () => import('./pages/recover/recover.module').then( m => m.RecoverPageModule),
|
||||
canActivate: [RecoveryNavGuard],
|
||||
},
|
||||
{
|
||||
path: 'embassy',
|
||||
loadChildren: () => import('./pages/embassy/embassy.module').then( m => m.EmbassyPageModule),
|
||||
canActivate: [NavGuard],
|
||||
},
|
||||
{
|
||||
path: 'loading',
|
||||
loadChildren: () => import('./pages/loading/loading.module').then( m => m.LoadingPageModule),
|
||||
canActivate: [NavGuard],
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {
|
||||
scrollPositionRestoration: 'enabled',
|
||||
preloadingStrategy: PreloadAllModules,
|
||||
useHash: true,
|
||||
initialNavigation: 'disabled',
|
||||
}),
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
@@ -0,0 +1,3 @@
|
||||
<ion-app>
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
</ion-app>
|
||||
36
frontend/projects/setup-wizard/src/app/app.component.ts
Normal file
36
frontend/projects/setup-wizard/src/app/app.component.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { ErrorToastService } from './services/error-toast.service'
|
||||
import { StateService } from './services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
})
|
||||
export class AppComponent {
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly stateService: StateService,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
try {
|
||||
const status = await this.apiService.getStatus()
|
||||
if (status.migrating || status['product-key']) {
|
||||
this.stateService.hasProductKey = true
|
||||
this.stateService.isMigrating = status.migrating
|
||||
await this.navCtrl.navigateForward(`/product-key`)
|
||||
} else {
|
||||
this.stateService.hasProductKey = false
|
||||
this.stateService.isMigrating = false
|
||||
await this.navCtrl.navigateForward(`/recover`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorToastService.present(`${e.message}: ${e.details}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
64
frontend/projects/setup-wizard/src/app/app.module.ts
Normal file
64
frontend/projects/setup-wizard/src/app/app.module.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ErrorHandler, NgModule } from '@angular/core'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
import { RouteReuseStrategy } from '@angular/router'
|
||||
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 { HttpService } from './services/api/http.service'
|
||||
import {
|
||||
IonicModule,
|
||||
IonicRouteStrategy,
|
||||
iosTransitionAnimation,
|
||||
} from '@ionic/angular'
|
||||
import { AppComponent } from './app.component'
|
||||
import { AppRoutingModule } from './app-routing.module'
|
||||
import { GlobalErrorHandler } from './services/global-error-handler.service'
|
||||
import { SuccessPageModule } from './pages/success/success.module'
|
||||
import { InitPageModule } from './pages/init/init.module'
|
||||
import { HomePageModule } from './pages/home/home.module'
|
||||
import { LoadingPageModule } from './pages/loading/loading.module'
|
||||
import { ProdKeyModalModule } from './modals/prod-key-modal/prod-key-modal.module'
|
||||
import { ProductKeyPageModule } from './pages/product-key/product-key.module'
|
||||
import { RecoverPageModule } from './pages/recover/recover.module'
|
||||
import { WorkspaceConfig } from '@shared'
|
||||
|
||||
const { useMocks } = require('../../../../config.json') as WorkspaceConfig
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
entryComponents: [],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
IonicModule.forRoot({
|
||||
mode: 'md',
|
||||
navAnimation: iosTransitionAnimation,
|
||||
}),
|
||||
AppRoutingModule,
|
||||
HttpClientModule,
|
||||
SuccessPageModule,
|
||||
HomePageModule,
|
||||
LoadingPageModule,
|
||||
ProdKeyModalModule,
|
||||
ProductKeyPageModule,
|
||||
RecoverPageModule,
|
||||
InitPageModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||
{
|
||||
provide: ApiService,
|
||||
useFactory: (http: HttpService) => {
|
||||
if (useMocks) {
|
||||
return new MockApiService()
|
||||
} else {
|
||||
return new LiveApiService(http)
|
||||
}
|
||||
},
|
||||
deps: [HttpService],
|
||||
},
|
||||
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
43
frontend/projects/setup-wizard/src/app/guards/nav-guard.ts
Normal file
43
frontend/projects/setup-wizard/src/app/guards/nav-guard.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { CanActivate, Router } from '@angular/router'
|
||||
import { HttpService } from '../services/api/http.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class NavGuard implements CanActivate {
|
||||
constructor (
|
||||
private readonly router: Router,
|
||||
private readonly httpService: HttpService,
|
||||
) { }
|
||||
|
||||
canActivate (): boolean {
|
||||
if (this.httpService.productKey) {
|
||||
return true
|
||||
} else {
|
||||
this.router.navigateByUrl('product-key')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class RecoveryNavGuard implements CanActivate {
|
||||
constructor (
|
||||
private readonly router: Router,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly stateService: StateService,
|
||||
) { }
|
||||
|
||||
canActivate (): boolean {
|
||||
if (this.httpService.productKey || !this.stateService.hasProductKey) {
|
||||
return true
|
||||
} else {
|
||||
this.router.navigateByUrl('product-key')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { CifsModal } from './cifs-modal.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
CifsModal,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
],
|
||||
exports: [
|
||||
CifsModal,
|
||||
],
|
||||
})
|
||||
export class CifsModalModule { }
|
||||
@@ -0,0 +1,82 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>
|
||||
Connect Shared Folder
|
||||
</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<form (ngSubmit)="submit()" #cifsForm="ngForm">
|
||||
<p>Hostname *</p>
|
||||
<ion-item>
|
||||
<ion-input
|
||||
id="hostname"
|
||||
required
|
||||
[(ngModel)]="cifs.hostname"
|
||||
name="hostname"
|
||||
#hostname="ngModel"
|
||||
placeholder="e.g. 'My Computer' OR 'my-computer.local'"
|
||||
pattern="^[a-zA-Z0-9._-]+( [a-zA-Z0-9]+)*$"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
<p [hidden]="hostname.valid || hostname.pristine">
|
||||
<ion-text color="danger">Hostname is required. e.g. 'My Computer' OR 'my-computer.local'</ion-text>
|
||||
</p>
|
||||
|
||||
<p>Path *</p>
|
||||
<ion-item>
|
||||
<ion-input
|
||||
id="path"
|
||||
required
|
||||
[(ngModel)]="cifs.path"
|
||||
name="path"
|
||||
#path="ngModel"
|
||||
placeholder="ex. /Desktop/my-folder'"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
<p [hidden]="path.valid || path.pristine">
|
||||
<ion-text color="danger">Path is required</ion-text>
|
||||
</p>
|
||||
|
||||
<p>Username *</p>
|
||||
<ion-item>
|
||||
<ion-input
|
||||
id="username"
|
||||
required
|
||||
[(ngModel)]="cifs.username"
|
||||
name="username"
|
||||
#username="ngModel"
|
||||
placeholder="Enter username"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
<p [hidden]="username.valid || username.pristine">
|
||||
<ion-text color="danger">Username is required</ion-text>
|
||||
</p>
|
||||
|
||||
<p>Password</p>
|
||||
<ion-item>
|
||||
<ion-input
|
||||
id="password"
|
||||
type="password"
|
||||
[(ngModel)]="cifs.password"
|
||||
name="password"
|
||||
#password="ngModel"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
|
||||
<button hidden type="submit"></button>
|
||||
</form>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" (click)="cancel()">
|
||||
Cancel
|
||||
</ion-button>
|
||||
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" strong="true" [disabled]="!cifsForm.form.valid" (click)="submit()">
|
||||
Verify
|
||||
</ion-button>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ion-content {
|
||||
--ion-text-color: var(--ion-color-dark);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { AlertController, LoadingController, ModalController } from '@ionic/angular'
|
||||
import { ApiService, CifsBackupTarget, EmbassyOSRecoveryInfo } from 'src/app/services/api/api.service'
|
||||
import { PasswordPage } from '../password/password.page'
|
||||
|
||||
@Component({
|
||||
selector: 'cifs-modal',
|
||||
templateUrl: 'cifs-modal.page.html',
|
||||
styleUrls: ['cifs-modal.page.scss'],
|
||||
})
|
||||
export class CifsModal {
|
||||
cifs = {
|
||||
type: 'cifs' as 'cifs',
|
||||
hostname: '',
|
||||
path: '',
|
||||
username: '',
|
||||
password: '',
|
||||
}
|
||||
|
||||
constructor (
|
||||
private readonly modalController: ModalController,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
) { }
|
||||
|
||||
cancel () {
|
||||
this.modalController.dismiss()
|
||||
}
|
||||
|
||||
async submit (): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
message: 'Connecting to shared folder...',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const embassyOS = await this.apiService.verifyCifs(this.cifs)
|
||||
const is02x = embassyOS.version.startsWith('0.2')
|
||||
|
||||
if (is02x) {
|
||||
this.modalController.dismiss({
|
||||
cifs: this.cifs,
|
||||
}, 'success')
|
||||
} else {
|
||||
this.presentModalPassword(embassyOS)
|
||||
}
|
||||
} catch (e) {
|
||||
this.presentAlertFailed()
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async presentModalPassword (embassyOS: EmbassyOSRecoveryInfo): Promise<void> {
|
||||
const target: CifsBackupTarget = {
|
||||
...this.cifs,
|
||||
mountable: true,
|
||||
'embassy-os': embassyOS,
|
||||
}
|
||||
|
||||
const modal = await this.modalController.create({
|
||||
component: PasswordPage,
|
||||
componentProps: { target },
|
||||
cssClass: 'alertlike-modal',
|
||||
})
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.role === 'success') {
|
||||
this.modalController.dismiss({
|
||||
cifs: this.cifs,
|
||||
recoveryPassword: res.data.password,
|
||||
}, 'success')
|
||||
}
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async presentAlertFailed (): Promise<void> {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Connection Failed',
|
||||
message: '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.',
|
||||
buttons: ['OK'],
|
||||
})
|
||||
alert.present()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { PasswordPage } from './password.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
PasswordPage,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
],
|
||||
exports: [
|
||||
PasswordPage,
|
||||
],
|
||||
})
|
||||
export class PasswordPageModule { }
|
||||
@@ -0,0 +1,72 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>
|
||||
{{ !!storageDrive ? 'Set Password' : 'Unlock Drive' }}
|
||||
</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<div style="padding: 8px 24px;">
|
||||
<div style="padding-bottom: 16px;">
|
||||
<ng-container *ngIf="!!storageDrive">
|
||||
<p>Choose a password for your Embassy. <i>Make it good. Write it down.</i></p>
|
||||
<p style="color: var(--ion-color-warning);">Losing your password can result in total loss of data.</p>
|
||||
</ng-container>
|
||||
<p *ngIf="!storageDrive">Enter the password that was used to encrypt this drive.</p>
|
||||
</div>
|
||||
|
||||
<form (ngSubmit)="!!storageDrive ? submitPw() : verifyPw()">
|
||||
<p>Password</p>
|
||||
<ion-item [class]="pwError ? 'error-border' : password && !!storageDrive ? 'success-border' : ''">
|
||||
<ion-input
|
||||
#focusInput
|
||||
[(ngModel)]="password"
|
||||
[ngModelOptions]="{'standalone': true}"
|
||||
[type]="!unmasked1 ? 'password' : 'text'"
|
||||
placeholder="Enter Password"
|
||||
(ionChange)="validate()"
|
||||
maxlength="64"
|
||||
></ion-input>
|
||||
<ion-button fill="clear" color="light" (click)="unmasked1 = !unmasked1">
|
||||
<ion-icon slot="icon-only" [name]="unmasked1 ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<div style="height: 16px;">
|
||||
<p style="color: var(--ion-color-danger); font-size: x-small;">{{ pwError }}</p>
|
||||
</div>
|
||||
<ng-container *ngIf="!!storageDrive">
|
||||
<p>Confirm Password</p>
|
||||
<ion-item [class]="verError ? 'error-border' : passwordVer ? 'success-border' : ''">
|
||||
<ion-input
|
||||
[(ngModel)]="passwordVer"
|
||||
[ngModelOptions]="{'standalone': true}"
|
||||
[type]="!unmasked2 ? 'password' : 'text'"
|
||||
(ionChange)="checkVer()"
|
||||
maxlength="64"
|
||||
placeholder="Retype Password"
|
||||
></ion-input>
|
||||
<ion-button fill="clear" color="light" (click)="unmasked2 = !unmasked2">
|
||||
<ion-icon slot="icon-only" [name]="unmasked2 ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<div style="height: 16px;">
|
||||
<p style="color: var(--ion-color-danger); font-size: x-small;">{{ verError }}</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
<input type="submit" style="display: none" />
|
||||
</form>
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" (click)="cancel()">
|
||||
Cancel
|
||||
</ion-button>
|
||||
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" strong="true" (click)="!!storageDrive ? submitPw() : verifyPw()">
|
||||
{{ !!storageDrive ? 'Finish' : 'Unlock' }}
|
||||
</ion-button>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ion-content {
|
||||
--ion-text-color: var(--ion-color-dark);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Component, Input, ViewChild } from '@angular/core'
|
||||
import { IonInput, ModalController } from '@ionic/angular'
|
||||
import { DiskInfo, CifsBackupTarget, DiskBackupTarget } from 'src/app/services/api/api.service'
|
||||
import * as argon2 from '@start9labs/argon2'
|
||||
|
||||
@Component({
|
||||
selector: 'app-password',
|
||||
templateUrl: 'password.page.html',
|
||||
styleUrls: ['password.page.scss'],
|
||||
})
|
||||
export class PasswordPage {
|
||||
@ViewChild('focusInput') elem: IonInput
|
||||
@Input() target: CifsBackupTarget | DiskBackupTarget
|
||||
@Input() storageDrive: DiskInfo
|
||||
|
||||
pwError = ''
|
||||
password = ''
|
||||
unmasked1 = false
|
||||
|
||||
verError = ''
|
||||
passwordVer = ''
|
||||
unmasked2 = false
|
||||
|
||||
constructor (
|
||||
private modalController: ModalController,
|
||||
) { }
|
||||
|
||||
ngAfterViewInit () {
|
||||
setTimeout(() => this.elem.setFocus(), 400)
|
||||
}
|
||||
|
||||
async verifyPw () {
|
||||
if (!this.target || !this.target['embassy-os']) this.pwError = 'No recovery target' // unreachable
|
||||
|
||||
try {
|
||||
argon2.verify(this.target['embassy-os']['password-hash'], this.password)
|
||||
this.modalController.dismiss({ password: this.password }, 'success')
|
||||
} catch (e) {
|
||||
this.pwError = 'Incorrect password provided'
|
||||
}
|
||||
}
|
||||
|
||||
async submitPw () {
|
||||
this.validate()
|
||||
if (this.password !== this.passwordVer) {
|
||||
this.verError = '*passwords do not match'
|
||||
}
|
||||
|
||||
if (this.pwError || this.verError) return
|
||||
this.modalController.dismiss({ password: this.password }, 'success')
|
||||
}
|
||||
|
||||
validate () {
|
||||
if (!!this.target) return this.pwError = ''
|
||||
|
||||
if (this.passwordVer) {
|
||||
this.checkVer()
|
||||
}
|
||||
|
||||
if (this.password.length < 12) {
|
||||
this.pwError = 'Must be 12 characters or greater'
|
||||
} else {
|
||||
this.pwError = ''
|
||||
}
|
||||
}
|
||||
|
||||
checkVer () {
|
||||
this.verError = this.password !== this.passwordVer ? 'Passwords do not match' : ''
|
||||
}
|
||||
|
||||
cancel () {
|
||||
this.modalController.dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ProdKeyModal } from './prod-key-modal.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
ProdKeyModal,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
],
|
||||
exports: [
|
||||
ProdKeyModal,
|
||||
],
|
||||
})
|
||||
export class ProdKeyModalModule { }
|
||||
@@ -0,0 +1,41 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>
|
||||
Enter Product Key
|
||||
</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<form (ngSubmit)="verifyProductKey()">
|
||||
<div style="padding: 8px 24px;">
|
||||
<div style="padding-bottom: 16px;">
|
||||
<p>Enter your 0.2.x Product Key to establish an encrypted connection with your new Embassy.</p>
|
||||
</div>
|
||||
<ion-item>
|
||||
<ion-input
|
||||
#focusInput
|
||||
[(ngModel)]="productKey"
|
||||
placeholder="Enter Product Key"
|
||||
maxlength="12"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
<div style="height: 16px;">
|
||||
<p style="color: var(--ion-color-danger); font-size: x-small;">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="submit" style="display: none" />
|
||||
</form>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" (click)="cancel()">
|
||||
Cancel
|
||||
</ion-button>
|
||||
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" strong="true" (click)="verifyProductKey()">
|
||||
Submit
|
||||
</ion-button>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ion-content {
|
||||
--ion-text-color: var(--ion-color-dark);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Component, Input, ViewChild } from '@angular/core'
|
||||
import { IonInput, LoadingController, ModalController } from '@ionic/angular'
|
||||
import { ApiService, DiskBackupTarget } from 'src/app/services/api/api.service'
|
||||
import { HttpService } from 'src/app/services/api/http.service'
|
||||
|
||||
@Component({
|
||||
selector: 'prod-key-modal',
|
||||
templateUrl: 'prod-key-modal.page.html',
|
||||
styleUrls: ['prod-key-modal.page.scss'],
|
||||
})
|
||||
export class ProdKeyModal {
|
||||
@ViewChild('focusInput') elem: IonInput
|
||||
@Input() target: DiskBackupTarget
|
||||
|
||||
error = ''
|
||||
productKey = ''
|
||||
unmasked = false
|
||||
|
||||
constructor (
|
||||
private readonly modalController: ModalController,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly httpService: HttpService,
|
||||
) { }
|
||||
|
||||
ngAfterViewInit () {
|
||||
setTimeout(() => this.elem.setFocus(), 400)
|
||||
}
|
||||
|
||||
async verifyProductKey () {
|
||||
if (!this.productKey) return
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Verifying Product Key',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.apiService.set02XDrive(this.target.logicalname)
|
||||
this.httpService.productKey = this.productKey
|
||||
await this.apiService.verifyProductKey()
|
||||
this.modalController.dismiss({ productKey: this.productKey }, 'success')
|
||||
} catch (e) {
|
||||
this.httpService.productKey = undefined
|
||||
this.error = 'Invalid Product Key'
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
cancel () {
|
||||
this.modalController.dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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 { }
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { EmbassyPage } from './embassy.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
import { EmbassyPageRoutingModule } from './embassy-routing.module'
|
||||
import { PipesModule } from 'src/app/pipes/pipe.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
EmbassyPageRoutingModule,
|
||||
PasswordPageModule,
|
||||
PipesModule,
|
||||
],
|
||||
declarations: [EmbassyPage],
|
||||
})
|
||||
export class EmbassyPageModule { }
|
||||
@@ -0,0 +1,55 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col class="ion-text-center">
|
||||
|
||||
<div style="padding-bottom: 32px;">
|
||||
<img src="assets/img/logo.png" style="max-width: 240px;" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<ion-card-subtitle>Select the drive where your Embassy data will be stored.</ion-card-subtitle>
|
||||
</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>
|
||||
<ion-card-subtitle>Please connect a storage drive to your Embassy and click "Refresh".</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
</ng-template>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<!-- loading -->
|
||||
<ion-spinner *ngIf="loading; else loaded" class="center-spinner" name="lines"></ion-spinner>
|
||||
|
||||
<!-- not loading -->
|
||||
<ng-template #loaded>
|
||||
<ng-container *ngIf="!storageDrives.length">
|
||||
<ion-button fill="clear" color="primary" (click)="getDrives()">
|
||||
<ion-icon slot="start" name='refresh'></ion-icon>
|
||||
Refresh
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
|
||||
<ion-item-group *ngIf="storageDrives.length">
|
||||
<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" color="light"></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>
|
||||
</ng-template>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { AlertController, LoadingController, ModalController, NavController } from '@ionic/angular'
|
||||
import { ApiService, DiskInfo, DiskRecoverySource } from 'src/app/services/api/api.service'
|
||||
import { ErrorToastService } from 'src/app/services/error-toast.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PasswordPage } from '../../modals/password/password.page'
|
||||
|
||||
@Component({
|
||||
selector: 'app-embassy',
|
||||
templateUrl: 'embassy.page.html',
|
||||
styleUrls: ['embassy.page.scss'],
|
||||
})
|
||||
export class EmbassyPage {
|
||||
storageDrives: DiskInfo[] = []
|
||||
loading = true
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly modalController: ModalController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly stateService: StateService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
tooSmall (drive: DiskInfo) {
|
||||
return drive.capacity < 34359738368
|
||||
}
|
||||
|
||||
async refresh () {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async getDrives () {
|
||||
this.loading = true
|
||||
try {
|
||||
const { disks, reconnect } = await this.apiService.getDrives()
|
||||
this.storageDrives = disks.filter(d => !d.partitions.map(p => p.logicalname).includes((this.stateService.recoverySource as DiskRecoverySource)?.logicalname))
|
||||
if (!this.storageDrives.length && reconnect.length) {
|
||||
const list = `<ul>${reconnect.map(recon => `<li>${recon}</li>`)}</ul>`
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message: `One or more devices you connected had to be reconfigured to support the current hardware platform. Please unplug and replug the following device(s), then refresh the page:<br> ${list}`,
|
||||
buttons: [
|
||||
{
|
||||
role: 'cancel',
|
||||
text: 'OK',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorToastService.present(e.message)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async chooseDrive (drive: DiskInfo) {
|
||||
if (!!drive.partitions.find(p => p.used) || !!drive.guid) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
subHeader: 'Drive contains data!',
|
||||
message: 'All data stored on this drive will be permanently deleted.',
|
||||
buttons: [
|
||||
{
|
||||
role: 'cancel',
|
||||
text: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Continue',
|
||||
handler: () => {
|
||||
if (this.stateService.recoveryPassword) {
|
||||
this.setupEmbassy(drive, this.stateService.recoveryPassword)
|
||||
} else {
|
||||
this.presentModalPassword(drive)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
} else {
|
||||
if (this.stateService.recoveryPassword) {
|
||||
this.setupEmbassy(drive, this.stateService.recoveryPassword)
|
||||
} else {
|
||||
this.presentModalPassword(drive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async presentModalPassword (drive: DiskInfo): Promise<void> {
|
||||
const modal = await this.modalController.create({
|
||||
component: PasswordPage,
|
||||
componentProps: {
|
||||
storageDrive: drive,
|
||||
},
|
||||
})
|
||||
modal.onDidDismiss().then(async ret => {
|
||||
if (!ret.data || !ret.data.password) return
|
||||
this.setupEmbassy(drive, ret.data.password)
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async setupEmbassy (drive: DiskInfo, password: string): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Transferring encrypted data. This could take a while...',
|
||||
})
|
||||
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.stateService.setupEmbassy(drive.logicalname, password)
|
||||
if (!!this.stateService.recoverySource) {
|
||||
await this.navCtrl.navigateForward(`/loading`)
|
||||
} else {
|
||||
await this.navCtrl.navigateForward(`/init`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorToastService.present(`${e.message}: ${e.details}. Restart Embassy to try again.`)
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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 { }
|
||||
@@ -0,0 +1,21 @@
|
||||
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'
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
HomePageRoutingModule,
|
||||
PasswordPageModule,
|
||||
],
|
||||
declarations: [HomePage],
|
||||
})
|
||||
export class HomePageModule { }
|
||||
@@ -0,0 +1,40 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col class="ion-text-center">
|
||||
|
||||
<div style="padding-bottom: 32px;">
|
||||
<img src="assets/img/logo.png" style="max-width: 240px;" />
|
||||
</div>
|
||||
|
||||
<ion-card color="dark">
|
||||
<ion-card-content class="ion-margin">
|
||||
<!-- fresh -->
|
||||
<ion-card
|
||||
routerLink="/embassy"
|
||||
color="light"
|
||||
style="text-align: center; background-color: #00919b !important; height: 160px; margin-bottom: 20px; box-shadow: 4px 4px 16px var(--ion-color-light);"
|
||||
>
|
||||
<ion-card-header>
|
||||
<ion-card-title style="font-size: 40px;">Start Fresh</ion-card-title>
|
||||
<ion-card-subtitle>Get started with a brand new Embassy</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
|
||||
<!-- recover -->
|
||||
</ion-card>
|
||||
<ion-card
|
||||
routerLink="/recover"
|
||||
color="light"
|
||||
style="text-align: center; background-color: #bf5900 !important; height: 160px; box-shadow: 4px 4px 16px var(--ion-color-light);"
|
||||
>
|
||||
<ion-card-header>
|
||||
<ion-card-title style="font-size: 40px;">Recover</ion-card-title>
|
||||
<ion-card-subtitle>Restore from backup or recover an old Embassy</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
</ion-card>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
})
|
||||
export class HomePage { }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { InitPage } from './init.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: InitPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class InitPageRoutingModule { }
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { InitPage } from './init.page'
|
||||
import { InitPageRoutingModule } from './init-routing.module'
|
||||
import { SuccessPageModule } from '../success/success.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
InitPageRoutingModule,
|
||||
SuccessPageModule,
|
||||
],
|
||||
declarations: [InitPage],
|
||||
exports: [InitPage],
|
||||
})
|
||||
export class InitPageModule { }
|
||||
@@ -0,0 +1,26 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col class="ion-text-center">
|
||||
|
||||
<div style="padding-bottom: 32px;">
|
||||
<img src="assets/img/logo.png" style="max-width: 240px;" />
|
||||
</div>
|
||||
|
||||
<success [hidden]="!stateService.embassyLoaded" (onDownload)="download()"></success>
|
||||
|
||||
<ion-card [hidden]="stateService.embassyLoaded" color="dark">
|
||||
<ion-card-header>
|
||||
<ion-card-title style="font-size: 40px;">Initializing Embassy</ion-card-title>
|
||||
<ion-card-subtitle>Progress: {{ progress }}%</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<ion-progress-bar color="primary" style="max-width: 700px; margin: auto; padding-bottom: 20px; margin-bottom: 40px;" [value]="progress / 100"></ion-progress-bar>
|
||||
<p class="ion-text-start">After completion, you will be prompted to download a file from your Embassy. Save the file somewhere safe, it is the easiest way to recover your Embassy's addresses and SSL certificate in case you lose them.</p>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { interval, Subscription } from 'rxjs'
|
||||
import { finalize, take, tap } from 'rxjs/operators'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-init',
|
||||
templateUrl: 'init.page.html',
|
||||
styleUrls: ['init.page.scss'],
|
||||
})
|
||||
export class InitPage {
|
||||
progress = 0
|
||||
sub: Subscription
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
public readonly stateService: StateService,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
// call setup.complete to tear down embassy.local and spin up embassy-[id].local
|
||||
this.apiService.setupComplete()
|
||||
|
||||
this.sub = interval(130)
|
||||
.pipe(
|
||||
take(101),
|
||||
tap(num => {
|
||||
this.progress = num
|
||||
}),
|
||||
finalize(() => {
|
||||
setTimeout(() => {
|
||||
this.stateService.embassyLoaded = true
|
||||
this.download()
|
||||
}, 500)
|
||||
}),
|
||||
).subscribe()
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
if (this.sub) this.sub.unsubscribe()
|
||||
}
|
||||
|
||||
download () {
|
||||
document.getElementById('tor-addr').innerHTML = this.stateService.torAddress
|
||||
document.getElementById('lan-addr').innerHTML = this.stateService.lanAddress
|
||||
document.getElementById('cert').setAttribute('href', 'data:application/x-x509-ca-cert;base64,' + encodeURIComponent(this.stateService.cert))
|
||||
let html = document.getElementById('downloadable').innerHTML
|
||||
const filename = 'embassy-info.html'
|
||||
|
||||
const elem = document.createElement('a')
|
||||
elem.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(html))
|
||||
elem.setAttribute('download', filename)
|
||||
elem.style.display = 'none'
|
||||
|
||||
document.body.appendChild(elem)
|
||||
elem.click()
|
||||
document.body.removeChild(elem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { LoadingPage } from './loading.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LoadingPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class LoadingPageRoutingModule { }
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { LoadingPage } from './loading.page'
|
||||
import { LoadingPageRoutingModule } from './loading-routing.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
LoadingPageRoutingModule,
|
||||
],
|
||||
declarations: [LoadingPage],
|
||||
})
|
||||
export class LoadingPageModule { }
|
||||
@@ -0,0 +1,24 @@
|
||||
<ion-content color="light">
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col class="ion-text-center">
|
||||
|
||||
<div style="padding-bottom: 32px;">
|
||||
<img src="assets/img/logo.png" style="max-width: 240px;" />
|
||||
</div>
|
||||
|
||||
<ion-card color="dark">
|
||||
<ion-card-header>
|
||||
<ion-card-title style="font-size: 40px;">Recovering</ion-card-title>
|
||||
<ion-card-subtitle>Progress: {{ (stateService.dataProgress * 100).toFixed(0) }}%</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<ion-progress-bar color="primary" style="max-width: 700px; margin: auto; padding-bottom: 20px; margin-bottom: 40px;" [value]="stateService.dataProgress"></ion-progress-bar>
|
||||
<p class="ion-text-start">After completion, you will be prompted to download a file from your Embassy. Save the file somewhere safe, it is the easiest way to recover your Embassy's addresses and SSL certificate in case you lose them.</p>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-loading',
|
||||
templateUrl: 'loading.page.html',
|
||||
styleUrls: ['loading.page.scss'],
|
||||
})
|
||||
export class LoadingPage {
|
||||
constructor (
|
||||
public stateService: StateService,
|
||||
private navCtrl: NavController,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.stateService.pollDataTransferProgress()
|
||||
const progSub = this.stateService.dataCompletionSubject.subscribe(async complete => {
|
||||
if (complete) {
|
||||
progSub.unsubscribe()
|
||||
await this.navCtrl.navigateForward(`/init`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { ProductKeyPage } from './product-key.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ProductKeyPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class ProductKeyPageRoutingModule { }
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ProductKeyPage } from './product-key.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
import { ProductKeyPageRoutingModule } from './product-key-routing.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
ProductKeyPageRoutingModule,
|
||||
PasswordPageModule,
|
||||
],
|
||||
declarations: [ProductKeyPage],
|
||||
})
|
||||
export class ProductKeyPageModule { }
|
||||
@@ -0,0 +1,43 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col class="ion-text-center">
|
||||
|
||||
<div style="padding-bottom: 32px;">
|
||||
<img src="assets/img/logo.png" style="max-width: 240px;" />
|
||||
</div>
|
||||
|
||||
<ion-card color="dark">
|
||||
<ion-card-header style="padding-bottom: 8px;">
|
||||
<ion-card-title>Product Key</ion-card-title>
|
||||
<ion-card-subtitle>Enter your product key to establish an encrypted connection with your Embassy</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<form (submit)="submit()" style="margin-bottom: 12px;">
|
||||
<ion-item-group class="ion-padding-bottom">
|
||||
<ion-item color="dark">
|
||||
<ion-icon slot="start" name="key-outline" style="margin-right: 16px;"></ion-icon>
|
||||
<ion-input
|
||||
#focusInput
|
||||
name="productKey"
|
||||
[(ngModel)]="productKey"
|
||||
(ionChange)="error = ''"
|
||||
maxlength="12"
|
||||
>
|
||||
</ion-input>
|
||||
</ion-item>
|
||||
<div class="ion-text-left">
|
||||
<p *ngIf="error" style="padding-top: 4px"><ion-text color="danger">{{ error }}</ion-text></p>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
<ion-button type="submit" color="light" class="claim-button">
|
||||
Submit
|
||||
</ion-button>
|
||||
</form>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,5 @@
|
||||
ion-item {
|
||||
--border-style: solid;
|
||||
--border-width: 1px;
|
||||
--border-color: var(--ion-color-medium);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Component, ViewChild } from '@angular/core'
|
||||
import { IonInput, LoadingController, NavController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { HttpService } from 'src/app/services/api/http.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-key',
|
||||
templateUrl: 'product-key.page.html',
|
||||
styleUrls: ['product-key.page.scss'],
|
||||
})
|
||||
export class ProductKeyPage {
|
||||
@ViewChild('focusInput') elem: IonInput
|
||||
productKey: string
|
||||
error: string
|
||||
|
||||
constructor (
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly stateService: StateService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly httpService: HttpService,
|
||||
) { }
|
||||
|
||||
ionViewDidEnter () {
|
||||
setTimeout(() => this.elem.setFocus(), 400)
|
||||
}
|
||||
|
||||
async submit () {
|
||||
if (!this.productKey) return this.error = 'Must enter product key'
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Verifying Product Key',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
this.httpService.productKey = this.productKey
|
||||
await this.apiService.verifyProductKey()
|
||||
if (this.stateService.isMigrating) {
|
||||
await this.navCtrl.navigateForward(`/loading`)
|
||||
} else {
|
||||
await this.navCtrl.navigateForward(`/home`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = 'Invalid Product Key'
|
||||
this.httpService.productKey = undefined
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="inline">
|
||||
<!-- has backup -->
|
||||
<h2 *ngIf="hasValidBackup; else noBackup">
|
||||
<ion-icon name="cloud-done" color="success"></ion-icon>
|
||||
{{ is02x ? 'Embassy 0.2.x detected' : 'Embassy backup detected' }}
|
||||
</h2>
|
||||
<!-- no backup -->
|
||||
<ng-template #noBackup>
|
||||
<h2>
|
||||
<ion-icon name="cloud-offline" color="danger"></ion-icon>
|
||||
No Embassy backup
|
||||
</h2>
|
||||
</ng-template>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
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 { }
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { DriveStatusComponent, RecoverPage } from './recover.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
import { ProdKeyModalModule } from '../../modals/prod-key-modal/prod-key-modal.module'
|
||||
import { RecoverPageRoutingModule } from './recover-routing.module'
|
||||
import { PipesModule } from 'src/app/pipes/pipe.module'
|
||||
import { CifsModalModule } from 'src/app/modals/cifs-modal/cifs-modal.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [RecoverPage, DriveStatusComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
RecoverPageRoutingModule,
|
||||
PasswordPageModule,
|
||||
ProdKeyModalModule,
|
||||
PipesModule,
|
||||
CifsModalModule,
|
||||
],
|
||||
})
|
||||
export class RecoverPageModule { }
|
||||
@@ -0,0 +1,65 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col>
|
||||
|
||||
<div style="padding-bottom: 32px;" class="ion-text-center">
|
||||
<img src="assets/img/logo.png" style="max-width: 240px;" />
|
||||
</div>
|
||||
|
||||
<ion-card color="dark">
|
||||
<ion-card-header class="ion-text-center">
|
||||
<ion-card-title>Recover</ion-card-title>
|
||||
<ion-card-subtitle>Select the shared folder or drive containing your Embassy backup</ion-card-subtitle>
|
||||
</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">
|
||||
<!-- cifs -->
|
||||
<h2 class="target-label">
|
||||
Shared Network Folder
|
||||
</h2>
|
||||
<p class="ion-padding-bottom">
|
||||
Using a shared folder is the recommended way to recover from backup, since it works with all Embassy hardware configurations.
|
||||
To recover from a shared folder, please follow the <a href="https://docs.start9.com/user-manual/general/backups.html#shared-network-folder" target="_blank" noreferrer>instructions</a>.
|
||||
</p>
|
||||
|
||||
<!-- connect -->
|
||||
<ion-item button lines="none" (click)="presentModalCifs()">
|
||||
<ion-icon slot="start" name="folder-open-outline" size="large" color="light"></ion-icon>
|
||||
<ion-label>Open Shared Folder</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<!-- drives -->
|
||||
<h2 class="target-label">
|
||||
Physical Drives
|
||||
</h2>
|
||||
<p class="ion-padding-bottom">
|
||||
Warning! Plugging in more than one physical drive to Embassy can lead to power failure and data corruption.
|
||||
To recover from a physical drive, please follow the <a href="https://docs.start9.com/user-manual/general/backups.html#physical-drive" target="_blank" noreferrer>instructions</a>.
|
||||
</p>
|
||||
|
||||
<ng-container *ngFor="let mapped of mappedDrives">
|
||||
<ion-item button *ngIf="mapped.drive as drive" [disabled]="!driveClickable(mapped)" (click)="select(drive)">
|
||||
<ion-icon slot="start" name="save-outline" size="large" color="light"></ion-icon>
|
||||
<ion-label>
|
||||
<h1>{{ drive.label || drive.logicalname }}</h1>
|
||||
<drive-status [hasValidBackup]="mapped.hasValidBackup" [is02x]="mapped.is02x"></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-item-group>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,4 @@
|
||||
.target-label {
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { AlertController, IonicSafeString, LoadingController, ModalController, NavController } from '@ionic/angular'
|
||||
import { CifsModal } from 'src/app/modals/cifs-modal/cifs-modal.page'
|
||||
import { ApiService, DiskBackupTarget } from 'src/app/services/api/api.service'
|
||||
import { ErrorToastService } from 'src/app/services/error-toast.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PasswordPage } from '../../modals/password/password.page'
|
||||
import { ProdKeyModal } from '../../modals/prod-key-modal/prod-key-modal.page'
|
||||
|
||||
@Component({
|
||||
selector: 'app-recover',
|
||||
templateUrl: 'recover.page.html',
|
||||
styleUrls: ['recover.page.scss'],
|
||||
})
|
||||
export class RecoverPage {
|
||||
loading = true
|
||||
mappedDrives: MappedDisk[] = []
|
||||
hasShownGuidAlert = false
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly modalController: ModalController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
public readonly stateService: StateService,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async refresh () {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
driveClickable (mapped: MappedDisk) {
|
||||
return mapped.drive['embassy-os']?.full && (this.stateService.hasProductKey || mapped.is02x)
|
||||
}
|
||||
|
||||
async getDrives () {
|
||||
this.mappedDrives = []
|
||||
try {
|
||||
const { disks, reconnect } = 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,
|
||||
is02x: drive['embassy-os']?.version.startsWith('0.2'),
|
||||
drive,
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
if (!this.mappedDrives.length && reconnect.length) {
|
||||
const list = `<ul>${reconnect.map(recon => `<li>${recon}</li>`)}</ul>`
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message: `One or more devices you connected had to be reconfigured to support the current hardware platform. Please unplug and replug the following device(s), then refresh the page:<br> ${list}`,
|
||||
buttons: [
|
||||
{
|
||||
role: 'cancel',
|
||||
text: 'OK',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
const importableDrive = disks.find(d => !!d.guid)
|
||||
if (!!importableDrive && !this.hasShownGuidAlert) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Embassy Data Drive Detected',
|
||||
message: new IonicSafeString(`${importableDrive.vendor || 'Unknown Vendor'} - ${importableDrive.model || 'Unknown Model' } contains Embassy data. To use this drive and its data <i>as-is</i>, click "Use Drive". This will complete the setup process.<br /><br /><b>Important</b>. If you are trying to restore from backup or update from 0.2.x, DO NOT click "Use Drive". Instead, click "Cancel" and follow instructions.`),
|
||||
buttons: [
|
||||
{
|
||||
role: 'cancel',
|
||||
text: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Use Drive',
|
||||
handler: async () => {
|
||||
await this.importDrive(importableDrive.guid)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
this.hasShownGuidAlert = true
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorToastService.present(`${e.message}: ${e.details}`)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async presentModalCifs (): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: CifsModal,
|
||||
})
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.role === 'success') {
|
||||
const { hostname, path, username, password } = res.data.cifs
|
||||
this.stateService.recoverySource = {
|
||||
type: 'cifs',
|
||||
hostname,
|
||||
path,
|
||||
username,
|
||||
password,
|
||||
}
|
||||
this.stateService.recoveryPassword = res.data.recoveryPassword
|
||||
this.navCtrl.navigateForward('/embassy')
|
||||
}
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async select (target: DiskBackupTarget) {
|
||||
const is02x = target['embassy-os'].version.startsWith('0.2')
|
||||
|
||||
if (this.stateService.hasProductKey) {
|
||||
if (is02x) {
|
||||
this.selectRecoverySource(target.logicalname)
|
||||
} else {
|
||||
const modal = await this.modalController.create({
|
||||
component: PasswordPage,
|
||||
componentProps: { target },
|
||||
cssClass: 'alertlike-modal',
|
||||
})
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.data && res.data.password) {
|
||||
this.selectRecoverySource(target.logicalname, res.data.password)
|
||||
}
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
// if no product key, it means they are an upgrade kit user
|
||||
} else {
|
||||
if (!is02x) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Error',
|
||||
message: 'In order to use this image, you must select a drive containing a valid 0.2.x Embassy.',
|
||||
buttons: [
|
||||
{
|
||||
role: 'cancel',
|
||||
text: 'OK',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
} else {
|
||||
const modal = await this.modalController.create({
|
||||
component: ProdKeyModal,
|
||||
componentProps: { target },
|
||||
cssClass: 'alertlike-modal',
|
||||
})
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.data && res.data.productKey) {
|
||||
this.selectRecoverySource(target.logicalname)
|
||||
}
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async importDrive (guid: string) {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Importing Drive',
|
||||
})
|
||||
await loader.present()
|
||||
try {
|
||||
await this.stateService.importDrive(guid)
|
||||
await this.navCtrl.navigateForward(`/init`)
|
||||
} catch (e) {
|
||||
this.errorToastService.present(`${e.message}: ${e.details}`)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async selectRecoverySource (logicalname: string, password?: string) {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'disk',
|
||||
logicalname,
|
||||
}
|
||||
this.stateService.recoveryPassword = password
|
||||
this.navCtrl.navigateForward(`/embassy`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'drive-status',
|
||||
templateUrl: './drive-status.component.html',
|
||||
styleUrls: ['./recover.page.scss'],
|
||||
})
|
||||
export class DriveStatusComponent {
|
||||
@Input() hasValidBackup: boolean
|
||||
@Input() is02x: boolean
|
||||
}
|
||||
|
||||
|
||||
interface MappedDisk {
|
||||
is02x: boolean
|
||||
hasValidBackup: boolean
|
||||
drive: DiskBackupTarget
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { SuccessPage } from './success.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
PasswordPageModule,
|
||||
],
|
||||
declarations: [SuccessPage],
|
||||
exports: [SuccessPage],
|
||||
})
|
||||
export class SuccessPageModule { }
|
||||
@@ -0,0 +1,167 @@
|
||||
|
||||
<ion-card color="dark">
|
||||
<ion-card-header class="ion-text-center" color="success">
|
||||
<ion-icon style="font-size: 80px;" name="checkmark-circle-outline"></ion-icon>
|
||||
<ion-card-title>Setup Complete!</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<br />
|
||||
<ng-template [ngIf]="stateService.recoverySource && stateService.recoverySource.type === 'disk'">
|
||||
<h2>You can now safely unplug your backup drive.</h2>
|
||||
</ng-template>
|
||||
<!-- Tor Instructions -->
|
||||
<div (click)="toggleTor()" class="toggle-label">
|
||||
<h2>Tor Instructions:</h2>
|
||||
<ion-icon
|
||||
name="chevron-down-outline"
|
||||
[ngStyle]="{
|
||||
'transform': torOpen ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
'transition': 'transform 0.4s ease-out'
|
||||
}"
|
||||
></ion-icon>
|
||||
</div>
|
||||
|
||||
<div
|
||||
[ngStyle]="{
|
||||
'overflow' : 'hidden',
|
||||
'max-height': torOpen ? '500px' : '0px',
|
||||
'transition': 'max-height 0.4s ease-out'
|
||||
}"
|
||||
>
|
||||
<div class="ion-padding ion-text-start">
|
||||
<p>
|
||||
To use your Embassy over Tor, visit its unique Tor address from any Tor-enabled browser.
|
||||
For a list of recommended browsers, click <a href="https://docs.start9.com/user-manual/connecting.html" target="_blank" rel="noreferrer"><b>here</b></a>.
|
||||
</p>
|
||||
<br />
|
||||
<p>Tor Address</p>
|
||||
<ion-item lines="none" color="dark">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<code><ion-text color="light">{{ stateService.torAddress }}</ion-text></code>
|
||||
</ion-label>
|
||||
<ion-button color="light" fill="clear" (click)="copy(stateService.torAddress)">
|
||||
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</div>
|
||||
<div style="padding-bottom: 24px; border-bottom: solid 1px;"></div>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<!-- LAN Instructions -->
|
||||
<div (click)="toggleLan()" class="toggle-label">
|
||||
<h2>LAN Instructions (Slightly Advanced):</h2>
|
||||
<ion-icon
|
||||
name="chevron-down-outline"
|
||||
[ngStyle]="{
|
||||
'transform': lanOpen ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
'transition': 'transform 0.4s ease-out'
|
||||
}"
|
||||
></ion-icon>
|
||||
</div>
|
||||
|
||||
<div
|
||||
[ngStyle]="{
|
||||
'overflow' : 'hidden',
|
||||
'max-height': lanOpen ? '500px' : '0px',
|
||||
'transition': 'max-height 0.4s ease-out'
|
||||
}"
|
||||
>
|
||||
<div class="ion-padding ion-text-start">
|
||||
<p>To use your Embassy locally, you must:</p>
|
||||
<ol>
|
||||
<li>Currently be connected to the same Local Area Network (LAN) as your Embassy.</li>
|
||||
<li>Download your Embassy's Root Certificate Authority.</li>
|
||||
<li>Trust your Embassy's Root CA on <i>both</i> your computer/phone and in your browser settings.</li>
|
||||
</ol>
|
||||
<p>
|
||||
For step-by-step instructions, click
|
||||
<a href="https://docs.start9.com/user-manual/general/lan-setup.html" target="_blank" rel="noreferrer"><b>here</b></a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>Please note, once setup is complete, the embassy.local address will no longer connect to your Embassy.</b>
|
||||
</p>
|
||||
|
||||
<ion-button style="margin-top: 24px; margin-bottom: 24px;" color="light" (click)="installCert()">
|
||||
Download Root CA
|
||||
<ion-icon slot="end" name="download-outline"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<p>LAN Address</p>
|
||||
<ion-item lines="none" color="dark">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<code><ion-text color="light">{{ stateService.lanAddress }}</ion-text></code>
|
||||
</ion-label>
|
||||
<ion-button color="light" fill="clear" (click)="copy(stateService.lanAddress)">
|
||||
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</div>
|
||||
<div style="padding-bottom: 24px; border-bottom: solid 1px;"></div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="ion-text-center ion-padding-top">
|
||||
<ion-button color="light" fill="clear" color="primary" strong (click)="download()">
|
||||
Download this page
|
||||
<ion-icon slot="end" name="download-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
<br />
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
|
||||
<!-- cert elem -->
|
||||
<a hidden id="install-cert" download="embassy.crt"></a>
|
||||
|
||||
<!-- download elem -->
|
||||
<div hidden id="downloadable">
|
||||
<div style="padding: 0 24px; font-family: Courier;">
|
||||
<h1>Embassy Info</h1>
|
||||
|
||||
<section style="padding: 16px; border: solid 1px;">
|
||||
<h2>Tor Info</h2>
|
||||
<p>
|
||||
To use your Embassy over Tor, visit its unique Tor address from any Tor-enabled browser.
|
||||
</p>
|
||||
<p>
|
||||
For a list of recommended browsers, click <a href="https://docs.start9.com/user-manual/connecting.html" target="_blank" rel="noreferrer"><b>here</b></a>.
|
||||
</p>
|
||||
<p><b>Tor Address: </b><code id="tor-addr"></code></p>
|
||||
</section>
|
||||
|
||||
<section style="padding: 16px; border: solid 1px; border-top: none;">
|
||||
<h2>LAN Info</h2>
|
||||
<p>To use your Embassy locally, you must:</p>
|
||||
<ol>
|
||||
<li>Currently be connected to the same Local Area Network (LAN) as your Embassy.</li>
|
||||
<li>Download your Embassy's Root Certificate Authority.</li>
|
||||
<li>Trust your Embassy's Root CA on <i>both</i> your computer/phone and in your browser settings.</li>
|
||||
</ol>
|
||||
<p>
|
||||
For step-by-step instructions, click
|
||||
<a href="https://docs.start9.com/user-manual/general/lan-setup.html" target="_blank" rel="noreferrer"><b>here</b></a>.
|
||||
</p>
|
||||
|
||||
<div style="margin: 42px 0;">
|
||||
<a
|
||||
id="cert"
|
||||
download="embassy.crt"
|
||||
style="
|
||||
background: #25272b;
|
||||
padding: 10px;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
"
|
||||
>
|
||||
Download Root CA
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p><b>LAN Address: </b><code id="lan-addr"></code></p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
p {
|
||||
color: var(--ion-color-light);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
padding: 24px 0 8px 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
* {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
text-align: right;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core'
|
||||
import { ToastController } from '@ionic/angular'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'success',
|
||||
templateUrl: 'success.page.html',
|
||||
styleUrls: ['success.page.scss'],
|
||||
})
|
||||
export class SuccessPage {
|
||||
@Output() onDownload = new EventEmitter()
|
||||
torOpen = true
|
||||
lanOpen = false
|
||||
|
||||
constructor (
|
||||
private readonly toastCtrl: ToastController,
|
||||
public readonly stateService: StateService,
|
||||
) { }
|
||||
|
||||
ngAfterViewInit () {
|
||||
document.getElementById('install-cert').setAttribute('href', 'data:application/x-x509-ca-cert;base64,' + encodeURIComponent(this.stateService.cert))
|
||||
}
|
||||
|
||||
async copy (address: string): Promise<void> {
|
||||
const success = await this.copyToClipboard(address)
|
||||
const message = success ? 'copied to clipboard!' : 'failed to copy'
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 1000,
|
||||
})
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
toggleTor () {
|
||||
this.torOpen = !this.torOpen
|
||||
}
|
||||
|
||||
toggleLan () {
|
||||
this.lanOpen = !this.lanOpen
|
||||
}
|
||||
|
||||
installCert () {
|
||||
document.getElementById('install-cert').click()
|
||||
}
|
||||
|
||||
download () {
|
||||
this.onDownload.emit()
|
||||
}
|
||||
|
||||
private async copyToClipboard (str: string): Promise<boolean> {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = str
|
||||
el.setAttribute('readonly', '')
|
||||
el.style.position = 'absolute'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
const copy = document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
|
||||
// converts bytes to gigabytes
|
||||
@Pipe({
|
||||
name: 'convertBytes',
|
||||
})
|
||||
export class ConvertBytesPipe implements PipeTransform {
|
||||
transform (bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
}
|
||||
10
frontend/projects/setup-wizard/src/app/pipes/pipe.module.ts
Normal file
10
frontend/projects/setup-wizard/src/app/pipes/pipe.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { ConvertBytesPipe } from './convert-bytes.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ConvertBytesPipe],
|
||||
imports: [],
|
||||
exports: [ConvertBytesPipe],
|
||||
})
|
||||
|
||||
export class PipesModule { }
|
||||
@@ -0,0 +1,98 @@
|
||||
export abstract class ApiService {
|
||||
// unencrypted
|
||||
abstract getStatus (): Promise<GetStatusRes> // setup.status
|
||||
abstract getDrives (): Promise<DiskListResponse> // setup.disk.list
|
||||
abstract set02XDrive (logicalname: string): Promise<void> // setup.recovery.v2.set
|
||||
abstract getRecoveryStatus (): Promise<RecoveryStatusRes> // setup.recovery.status
|
||||
|
||||
// encrypted
|
||||
abstract verifyCifs (cifs: CifsRecoverySource): Promise<EmbassyOSRecoveryInfo> // setup.cifs.verify
|
||||
abstract verifyProductKey (): Promise<void> // echo - throws error if invalid
|
||||
abstract importDrive (guid: string): Promise<SetupEmbassyRes> // setup.execute
|
||||
abstract setupEmbassy (setupInfo: SetupEmbassyReq): Promise<SetupEmbassyRes> // setup.execute
|
||||
abstract setupComplete (): Promise<void> // setup.complete
|
||||
}
|
||||
|
||||
export interface GetStatusRes {
|
||||
'product-key': boolean
|
||||
migrating: boolean
|
||||
}
|
||||
|
||||
export interface SetupEmbassyReq {
|
||||
'embassy-logicalname': string
|
||||
'embassy-password': string
|
||||
'recovery-source': CifsRecoverySource | DiskRecoverySource | null
|
||||
'recovery-password': string | null
|
||||
}
|
||||
|
||||
export interface SetupEmbassyRes {
|
||||
'tor-address': string
|
||||
'lan-address': string
|
||||
'root-ca': string
|
||||
}
|
||||
|
||||
export interface EmbassyOSRecoveryInfo {
|
||||
version: string
|
||||
full: boolean
|
||||
'password-hash': string | null
|
||||
'wrapped-key': string | null
|
||||
}
|
||||
|
||||
export interface DiskListResponse {
|
||||
disks: DiskInfo[]
|
||||
reconnect: string[]
|
||||
}
|
||||
|
||||
export interface DiskBackupTarget {
|
||||
vendor: string | null
|
||||
model: string | null
|
||||
logicalname: string | null
|
||||
label: string | null
|
||||
capacity: number
|
||||
used: number | null
|
||||
'embassy-os': EmbassyOSRecoveryInfo | null
|
||||
}
|
||||
|
||||
export interface CifsBackupTarget {
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
mountable: boolean
|
||||
'embassy-os': EmbassyOSRecoveryInfo | null
|
||||
}
|
||||
|
||||
export interface DiskRecoverySource {
|
||||
type: 'disk'
|
||||
logicalname: string // partition logicalname
|
||||
}
|
||||
|
||||
export interface CifsRecoverySource {
|
||||
type: 'cifs'
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
password: string | null
|
||||
}
|
||||
|
||||
export interface DiskInfo {
|
||||
logicalname: string,
|
||||
vendor: string | null,
|
||||
model: string | null,
|
||||
partitions: PartitionInfo[],
|
||||
capacity: number,
|
||||
guid: string | null, // cant back up if guid exists
|
||||
}
|
||||
|
||||
export interface RecoveryStatusRes {
|
||||
'bytes-transferred': number
|
||||
'total-bytes': number
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
export interface PartitionInfo {
|
||||
logicalname: string,
|
||||
label: string | null,
|
||||
capacity: number,
|
||||
used: number | null,
|
||||
'embassy-os': EmbassyOSRecoveryInfo | null,
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'
|
||||
import { Observable } from 'rxjs'
|
||||
import * as aesjs from 'aes-js'
|
||||
import * as pbkdf2 from 'pbkdf2'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class HttpService {
|
||||
fullUrl: string
|
||||
productKey: string
|
||||
|
||||
constructor (
|
||||
private readonly http: HttpClient,
|
||||
) {
|
||||
const port = window.location.port
|
||||
this.fullUrl = `${window.location.protocol}//${window.location.hostname}:${port}/rpc/v1`
|
||||
}
|
||||
|
||||
async rpcRequest<T> (body: RPCOptions, encrypted = true): Promise<T> {
|
||||
|
||||
const httpOpts = {
|
||||
method: Method.POST,
|
||||
body,
|
||||
url: this.fullUrl,
|
||||
}
|
||||
|
||||
let res: RPCResponse<T>
|
||||
|
||||
if (encrypted) {
|
||||
res = await this.encryptedHttpRequest<RPCResponse<T>>(httpOpts)
|
||||
} else {
|
||||
res = await this.httpRequest<RPCResponse<T>>(httpOpts)
|
||||
}
|
||||
|
||||
if (isRpcError(res)) {
|
||||
console.error('RPC ERROR: ', res)
|
||||
throw new RpcError(res.error)
|
||||
}
|
||||
|
||||
if (isRpcSuccess(res)) return res.result
|
||||
}
|
||||
|
||||
async encryptedHttpRequest<T> (httpOpts: {
|
||||
body: RPCOptions;
|
||||
url: string;
|
||||
}): Promise<T> {
|
||||
|
||||
const urlIsRelative = httpOpts.url.startsWith('/')
|
||||
const url = urlIsRelative ?
|
||||
this.fullUrl + httpOpts.url :
|
||||
httpOpts.url
|
||||
|
||||
const encryptedBody = await AES_CTR.encryptPbkdf2(this.productKey, encodeUtf8(JSON.stringify(httpOpts.body)))
|
||||
const options = {
|
||||
responseType: 'arraybuffer',
|
||||
body: encryptedBody.buffer,
|
||||
observe: 'events',
|
||||
reportProgress: false,
|
||||
|
||||
headers: {
|
||||
'Content-Encoding': 'aesctr256',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
} as any
|
||||
|
||||
const req = this.http.post(url, options.body, options)
|
||||
|
||||
return (req)
|
||||
.toPromise()
|
||||
.then(res => AES_CTR.decryptPbkdf2(this.productKey, (res as any).body as ArrayBuffer))
|
||||
.then(res => JSON.parse(res))
|
||||
.catch(e => {
|
||||
if (!e.status && !e.statusText) {
|
||||
throw new EncryptionError(e)
|
||||
} else {
|
||||
throw new HttpError(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async httpRequest<T> (httpOpts: {
|
||||
body: RPCOptions;
|
||||
url: string;
|
||||
}): Promise<T> {
|
||||
const urlIsRelative = httpOpts.url.startsWith('/')
|
||||
const url = urlIsRelative ?
|
||||
this.fullUrl + httpOpts.url :
|
||||
httpOpts.url
|
||||
|
||||
const options = {
|
||||
responseType: 'json',
|
||||
body: httpOpts.body,
|
||||
observe: 'events',
|
||||
reportProgress: false,
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
} as any
|
||||
|
||||
const req: Observable<{ body: T }> = this.http.post(url, httpOpts.body, options) as any
|
||||
|
||||
return (req)
|
||||
.toPromise()
|
||||
.then(res => res.body)
|
||||
.catch(e => { throw new HttpError(e) })
|
||||
}
|
||||
}
|
||||
|
||||
function RpcError (e: RPCError['error']): void {
|
||||
const { code, message, data } = e
|
||||
|
||||
this.code = code
|
||||
this.message = message
|
||||
|
||||
if (typeof data !== 'string') {
|
||||
this.details = data.details
|
||||
} else {
|
||||
this.details = data
|
||||
}
|
||||
}
|
||||
|
||||
function HttpError (e: HttpErrorResponse): void {
|
||||
const { status, statusText } = e
|
||||
|
||||
this.code = status
|
||||
this.message = statusText
|
||||
this.details = null
|
||||
}
|
||||
|
||||
function EncryptionError (e: HttpErrorResponse): void {
|
||||
this.code = null
|
||||
this.message = 'Invalid Key'
|
||||
this.details = null
|
||||
}
|
||||
|
||||
function isRpcError<Error, Result> (arg: { error: Error } | { result: Result }): arg is { error: Error } {
|
||||
return !!(arg as any).error
|
||||
}
|
||||
|
||||
function isRpcSuccess<Error, Result> (arg: { error: Error } | { result: Result }): arg is { result: Result } {
|
||||
return !!(arg as any).result
|
||||
}
|
||||
|
||||
export enum Method {
|
||||
GET = 'GET',
|
||||
POST = 'POST',
|
||||
PUT = 'PUT',
|
||||
PATCH = 'PATCH',
|
||||
DELETE = 'DELETE',
|
||||
}
|
||||
|
||||
export interface RPCOptions {
|
||||
method: string
|
||||
params?: {
|
||||
[param: string]: string | number | boolean | object | string[] | number[];
|
||||
}
|
||||
}
|
||||
|
||||
interface RPCBase {
|
||||
jsonrpc: '2.0'
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface RPCRequest<T> extends RPCBase {
|
||||
method: string
|
||||
params?: T
|
||||
}
|
||||
|
||||
export interface RPCSuccess<T> extends RPCBase {
|
||||
result: T
|
||||
}
|
||||
|
||||
export interface RPCError extends RPCBase {
|
||||
error: {
|
||||
code: number,
|
||||
message: string
|
||||
data?: {
|
||||
details: string
|
||||
} | string
|
||||
}
|
||||
}
|
||||
|
||||
export type RPCResponse<T> = RPCSuccess<T> | RPCError
|
||||
|
||||
type HttpError = HttpErrorResponse & { error: { code: string, message: string } }
|
||||
|
||||
export interface HttpOptions {
|
||||
method: Method
|
||||
url: string
|
||||
headers?: HttpHeaders | {
|
||||
[header: string]: string | string[]
|
||||
}
|
||||
params?: HttpParams | {
|
||||
[param: string]: string | string[]
|
||||
}
|
||||
responseType?: 'json' | 'text' | 'arrayBuffer'
|
||||
withCredentials?: boolean
|
||||
body?: any
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
type AES_CTR = {
|
||||
encryptPbkdf2: (secretKey: string, messageBuffer: Uint8Array) => Promise<Uint8Array>
|
||||
decryptPbkdf2: (secretKey, arr: ArrayBuffer) => Promise<string>
|
||||
}
|
||||
|
||||
export const AES_CTR: AES_CTR = {
|
||||
encryptPbkdf2: async (secretKey: string, messageBuffer: Uint8Array) => {
|
||||
const salt = window.crypto.getRandomValues(new Uint8Array(16))
|
||||
const counter = window.crypto.getRandomValues(new Uint8Array(16))
|
||||
|
||||
const key = pbkdf2.pbkdf2Sync(secretKey, salt, 1000, 256 / 8, 'sha256')
|
||||
|
||||
const aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(counter))
|
||||
const encryptedBytes = aesCtr.encrypt(messageBuffer)
|
||||
return new Uint8Array([...counter, ...salt, ...encryptedBytes])
|
||||
},
|
||||
decryptPbkdf2: async (secretKey: string, arr: ArrayBuffer) => {
|
||||
const buff = new Uint8Array(arr)
|
||||
const counter = buff.slice(0, 16)
|
||||
const salt = buff.slice(16, 32)
|
||||
|
||||
const cipher = buff.slice(32)
|
||||
const key = pbkdf2.pbkdf2Sync(secretKey, salt, 1000, 256 / 8, 'sha256')
|
||||
|
||||
const aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(counter))
|
||||
const decryptedBytes = aesCtr.decrypt(cipher)
|
||||
|
||||
return aesjs.utils.utf8.fromBytes(decryptedBytes)
|
||||
},
|
||||
}
|
||||
|
||||
export const encode16 = (buffer: Uint8Array) => buffer.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '')
|
||||
export const decode16 = hexString => new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16)))
|
||||
|
||||
export function encodeUtf8 (str: string): Uint8Array {
|
||||
const encoder = new TextEncoder()
|
||||
return encoder.encode(str)
|
||||
}
|
||||
|
||||
export function decodeUtf8 (arr: Uint8Array): string {
|
||||
return new TextDecoder().decode(arr)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ApiService, CifsRecoverySource, DiskInfo, DiskListResponse, DiskRecoverySource, EmbassyOSRecoveryInfo, GetStatusRes, RecoveryStatusRes, SetupEmbassyReq, SetupEmbassyRes } from './api.service'
|
||||
import { HttpService } from './http.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LiveApiService extends ApiService {
|
||||
|
||||
constructor (
|
||||
private readonly http: HttpService,
|
||||
) { super() }
|
||||
|
||||
// ** UNENCRYPTED **
|
||||
|
||||
async getStatus () {
|
||||
return this.http.rpcRequest<GetStatusRes>({
|
||||
method: 'setup.status',
|
||||
params: { },
|
||||
}, false)
|
||||
}
|
||||
|
||||
async getDrives () {
|
||||
return this.http.rpcRequest<DiskListResponse>({
|
||||
method: 'setup.disk.list',
|
||||
params: { },
|
||||
}, false)
|
||||
}
|
||||
|
||||
async set02XDrive (logicalname) {
|
||||
return this.http.rpcRequest<void>({
|
||||
method: 'setup.recovery.v2.set',
|
||||
params: { logicalname },
|
||||
}, false)
|
||||
}
|
||||
|
||||
async getRecoveryStatus () {
|
||||
return this.http.rpcRequest<RecoveryStatusRes>({
|
||||
method: 'setup.recovery.status',
|
||||
params: { },
|
||||
}, false)
|
||||
}
|
||||
|
||||
// ** ENCRYPTED **
|
||||
|
||||
async verifyCifs (source: CifsRecoverySource) {
|
||||
source.path = source.path.replace('/\\/g', '/')
|
||||
return this.http.rpcRequest<EmbassyOSRecoveryInfo>({
|
||||
method: 'setup.cifs.verify',
|
||||
params: source as any,
|
||||
})
|
||||
}
|
||||
|
||||
async verifyProductKey () {
|
||||
return this.http.rpcRequest<void>({
|
||||
method: 'echo',
|
||||
params: { 'message': 'hello' },
|
||||
})
|
||||
}
|
||||
|
||||
async importDrive (guid: string) {
|
||||
const res = await this.http.rpcRequest<SetupEmbassyRes>({
|
||||
method: 'setup.attach',
|
||||
params: { guid },
|
||||
})
|
||||
|
||||
return {
|
||||
...res,
|
||||
'root-ca': btoa(res['root-ca']),
|
||||
}
|
||||
}
|
||||
|
||||
async setupEmbassy (setupInfo: SetupEmbassyReq) {
|
||||
if (isCifsSource(setupInfo['recovery-source'])) {
|
||||
setupInfo['recovery-source'].path = setupInfo['recovery-source'].path.replace('/\\/g', '/')
|
||||
}
|
||||
|
||||
const res = await this.http.rpcRequest<SetupEmbassyRes>({
|
||||
method: 'setup.execute',
|
||||
params: setupInfo as any,
|
||||
})
|
||||
|
||||
return {
|
||||
...res,
|
||||
'root-ca': btoa(res['root-ca']),
|
||||
}
|
||||
}
|
||||
|
||||
async setupComplete () {
|
||||
await this.http.rpcRequest<SetupEmbassyRes>({
|
||||
method: 'setup.complete',
|
||||
params: { },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isCifsSource (source: CifsRecoverySource | DiskRecoverySource | undefined): source is CifsRecoverySource {
|
||||
return !!(source as CifsRecoverySource)?.hostname
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
import { ApiService, CifsRecoverySource, SetupEmbassyReq } from './api.service'
|
||||
|
||||
let tries = 0
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MockApiService extends ApiService {
|
||||
|
||||
constructor () {
|
||||
super()
|
||||
}
|
||||
|
||||
// ** UNENCRYPTED **
|
||||
|
||||
async getStatus () {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
'product-key': true,
|
||||
migrating: false,
|
||||
}
|
||||
}
|
||||
|
||||
async getDrives () {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
disks: [
|
||||
{
|
||||
logicalname: 'abcd',
|
||||
vendor: 'Samsung',
|
||||
model: 'T5',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pabcd',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
'embassy-os': {
|
||||
version: '0.2.17',
|
||||
full: true,
|
||||
'password-hash': null,
|
||||
'wrapped-key': null,
|
||||
},
|
||||
}
|
||||
],
|
||||
capacity: 123456789123,
|
||||
guid: 'uuid-uuid-uuid-uuid',
|
||||
}
|
||||
],
|
||||
reconnect: [],
|
||||
}
|
||||
}
|
||||
|
||||
async set02XDrive () {
|
||||
await pauseFor(1000)
|
||||
return
|
||||
}
|
||||
|
||||
async getRecoveryStatus () {
|
||||
tries = Math.min(tries + 1, 4)
|
||||
return {
|
||||
'bytes-transferred': tries,
|
||||
'total-bytes': 4,
|
||||
complete: tries === 4,
|
||||
}
|
||||
}
|
||||
|
||||
// ** ENCRYPTED **
|
||||
|
||||
async verifyCifs (params: CifsRecoverySource) {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
version: '0.3.0',
|
||||
full: true,
|
||||
'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
'wrapped-key': '',
|
||||
}
|
||||
}
|
||||
|
||||
async verifyProductKey () {
|
||||
await pauseFor(1000)
|
||||
return
|
||||
}
|
||||
|
||||
async importDrive (guid: string) {
|
||||
await pauseFor(3000)
|
||||
return setupRes
|
||||
}
|
||||
|
||||
async setupEmbassy (setupInfo: SetupEmbassyReq) {
|
||||
await pauseFor(3000)
|
||||
return setupRes
|
||||
}
|
||||
|
||||
async setupComplete () {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
}
|
||||
|
||||
const rootCA =
|
||||
`-----BEGIN CERTIFICATE-----
|
||||
MIIDpzCCAo+gAwIBAgIRAIIuOarlQETlUQEOZJGZYdIwDQYJKoZIhvcNAQELBQAw
|
||||
bTELMAkGA1UEBhMCVVMxFTATBgNVBAoMDEV4YW1wbGUgQ29ycDEOMAwGA1UECwwF
|
||||
U2FsZXMxCzAJBgNVBAgMAldBMRgwFgYDVQQDDA93d3cuZXhhbXBsZS5jb20xEDAO
|
||||
BgNVBAcMB1NlYXR0bGUwHhcNMjEwMzA4MTU0NjI3WhcNMjIwMzA4MTY0NjI3WjBt
|
||||
MQswCQYDVQQGEwJVUzEVMBMGA1UECgwMRXhhbXBsZSBDb3JwMQ4wDAYDVQQLDAVT
|
||||
YWxlczELMAkGA1UECAwCV0ExGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTEQMA4G
|
||||
A1UEBwwHU2VhdHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMP7
|
||||
t5AKFZQ7abqkeyUjsBVIWRa9tCh8oge9u/LvCbxU738G4jssT+Oud3WMajIjuNow
|
||||
cpc+0Q/e42ULO/6gTNrTs6OCOo9lV6G0Dprf/e91DWoKgPatem/pUjNyraifHZfu
|
||||
b5mLHCfahjWXUQtc/sjmDQaZRK3Kar6ljlUBE/Le9NEyOAIkSLPzDtW8LXm4iwcU
|
||||
BZrb828rKd1Aw9oI1+3bfzB6xXmzZxc5RLXveOCEhKGD32jKZ/RNFSC8AZAwJe+x
|
||||
bTsys/lUOYFTuT8Bn0TGxR8x7Y4H75+F9BavY3v+WkLj4M+olN9dMR7Et9FMt4u4
|
||||
YRokv5zp8zIb5iTne1kCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
|
||||
FgQUaW3+r328uTLokog2TklmoBK+yt4wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3
|
||||
DQEBCwUAA4IBAQAXjd/7UZ8RDE+PLWSDNGQdLemOBTcawF+tK+PzA4Evlmn9VuNc
|
||||
g+x3oZvVZSDQBANUz0b9oPeo54aE38dW1zQm2qfTab8822aqeWMLyJ1dMsAgqYX2
|
||||
t9+u6w3NzRCw8Pvz18V69+dFE5AeXmNP0Z5/gdz8H/NSpctjlzopbScRZKCSlPid
|
||||
Rf3ZOPm9QP92YpWyYDkfAU04xdDo1vR0MYjKPkl4LjRqSU/tcCJnPMbJiwq+bWpX
|
||||
2WJoEBXB/p15Kn6JxjI0ze2SnSI48JZ8it4fvxrhOo0VoLNIuCuNXJOwU17Rdl1W
|
||||
YJidaq7je6k18AdgPA0Kh8y1XtfUH3fTaVw4
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
const setupRes = {
|
||||
'tor-address': 'http://asdafsadasdasasdasdfasdfasdf.onion',
|
||||
'lan-address': 'https://embassy-abcdefgh.local',
|
||||
'root-ca': btoa(rootCA),
|
||||
}
|
||||
|
||||
const disks = [
|
||||
{
|
||||
vendor: 'Samsung',
|
||||
model: 'SATA',
|
||||
logicalname: '/dev/sda',
|
||||
guid: 'theguid',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'sda1',
|
||||
label: 'label 1',
|
||||
capacity: 100000,
|
||||
used: 200.1255312,
|
||||
'embassy-os': null,
|
||||
},
|
||||
{
|
||||
logicalname: 'sda2',
|
||||
label: 'label 2',
|
||||
capacity: 50000,
|
||||
used: 200.1255312,
|
||||
'embassy-os': null,
|
||||
},
|
||||
],
|
||||
capacity: 150000,
|
||||
},
|
||||
{
|
||||
vendor: 'Samsung',
|
||||
model: null,
|
||||
logicalname: 'dev/sdb',
|
||||
partitions: [],
|
||||
capacity: 34359738369,
|
||||
guid: null,
|
||||
},
|
||||
{
|
||||
vendor: 'Crucial',
|
||||
model: 'MX500',
|
||||
logicalname: 'dev/sdc',
|
||||
guid: null,
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'sdc1',
|
||||
label: 'label 1',
|
||||
capacity: 0,
|
||||
used: null,
|
||||
'embassy-os': {
|
||||
version: '0.3.3',
|
||||
full: true,
|
||||
'password-hash': 'asdfasdfasdf',
|
||||
'wrapped-key': '',
|
||||
},
|
||||
},
|
||||
{
|
||||
logicalname: 'sdc1MOCKTESTER',
|
||||
label: 'label 1',
|
||||
capacity: 0,
|
||||
used: null,
|
||||
'embassy-os': {
|
||||
version: '0.3.6',
|
||||
full: true,
|
||||
// password is 'asdfasdf'
|
||||
'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
'wrapped-key': '',
|
||||
},
|
||||
},
|
||||
{
|
||||
logicalname: 'sdc1',
|
||||
label: 'label 1',
|
||||
capacity: 0,
|
||||
used: null,
|
||||
'embassy-os': {
|
||||
version: '0.3.3',
|
||||
full: false,
|
||||
'password-hash': 'asdfasdfasdf',
|
||||
'wrapped-key': '',
|
||||
},
|
||||
},
|
||||
],
|
||||
capacity: 100000,
|
||||
},
|
||||
{
|
||||
vendor: 'Sandisk',
|
||||
model: null,
|
||||
logicalname: '/dev/sdd',
|
||||
guid: null,
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'sdd1',
|
||||
label: null,
|
||||
capacity: 10000,
|
||||
used: null,
|
||||
'embassy-os': {
|
||||
version: '0.2.7',
|
||||
full: true,
|
||||
'password-hash': 'asdfasdfasdf',
|
||||
'wrapped-key': '',
|
||||
},
|
||||
},
|
||||
],
|
||||
capacity: 10000,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ToastController } from '@ionic/angular'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ErrorToastService {
|
||||
private toast: HTMLIonToastElement
|
||||
|
||||
constructor (
|
||||
private readonly toastCtrl: ToastController,
|
||||
) { }
|
||||
|
||||
async present (message: string): Promise<void> {
|
||||
if (this.toast) return
|
||||
|
||||
this.toast = await this.toastCtrl.create({
|
||||
header: 'Error',
|
||||
message,
|
||||
duration: 0,
|
||||
position: 'top',
|
||||
cssClass: 'error-toast',
|
||||
animated: true,
|
||||
buttons: [
|
||||
{
|
||||
side: 'end',
|
||||
icon: 'close',
|
||||
handler: () => {
|
||||
this.dismiss()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await this.toast.present()
|
||||
}
|
||||
|
||||
async dismiss (): Promise<void> {
|
||||
if (this.toast) {
|
||||
await this.toast.dismiss()
|
||||
this.toast = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ErrorHandler, Injectable } from '@angular/core'
|
||||
|
||||
@Injectable()
|
||||
export class GlobalErrorHandler implements ErrorHandler {
|
||||
|
||||
handleError (e: any): void {
|
||||
console.error(e)
|
||||
const chunkFailedMessage = /Loading chunk [\d]+ failed/
|
||||
|
||||
if (chunkFailedMessage.test(e.message)) {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { ApiService, CifsRecoverySource, DiskRecoverySource } from './api/api.service'
|
||||
import { ErrorToastService } from './error-toast.service'
|
||||
import { pauseFor } from '../util/misc.util'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StateService {
|
||||
hasProductKey: boolean
|
||||
isMigrating: boolean
|
||||
|
||||
polling = false
|
||||
embassyLoaded = false
|
||||
|
||||
recoverySource: CifsRecoverySource | DiskRecoverySource
|
||||
recoveryPassword: string
|
||||
|
||||
dataTransferProgress: { bytesTransferred: number, totalBytes: number, complete: boolean } | null
|
||||
dataProgress = 0
|
||||
dataCompletionSubject = new BehaviorSubject(false)
|
||||
|
||||
torAddress: string
|
||||
lanAddress: string
|
||||
cert: string
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
) { }
|
||||
|
||||
async pollDataTransferProgress () {
|
||||
this.polling = true
|
||||
await pauseFor(500)
|
||||
|
||||
if (
|
||||
this.dataTransferProgress?.complete
|
||||
) {
|
||||
this.dataCompletionSubject.next(true)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let progress
|
||||
try {
|
||||
progress = await this.apiService.getRecoveryStatus()
|
||||
} catch (e) {
|
||||
this.errorToastService.present(`${e.message}: ${e.details}.\nRestart Embassy to try again.`)
|
||||
}
|
||||
if (progress) {
|
||||
this.dataTransferProgress = {
|
||||
bytesTransferred: progress['bytes-transferred'],
|
||||
totalBytes: progress['total-bytes'],
|
||||
complete: progress.complete,
|
||||
}
|
||||
if (this.dataTransferProgress.totalBytes) {
|
||||
this.dataProgress = this.dataTransferProgress.bytesTransferred / this.dataTransferProgress.totalBytes
|
||||
}
|
||||
}
|
||||
setTimeout(() => this.pollDataTransferProgress(), 0) // prevent call stack from growing
|
||||
}
|
||||
|
||||
async importDrive (guid: string): Promise<void> {
|
||||
const ret = await this.apiService.importDrive(guid)
|
||||
this.torAddress = ret['tor-address']
|
||||
this.lanAddress = ret['lan-address']
|
||||
this.cert = ret['root-ca']
|
||||
}
|
||||
|
||||
async setupEmbassy (storageLogicalname: string, password: string): Promise<void> {
|
||||
const ret = await this.apiService.setupEmbassy({
|
||||
'embassy-logicalname': storageLogicalname,
|
||||
'embassy-password': password,
|
||||
'recovery-source': this.recoverySource || null,
|
||||
'recovery-password': this.recoveryPassword || null,
|
||||
})
|
||||
this.torAddress = ret['tor-address']
|
||||
this.lanAddress = ret['lan-address']
|
||||
this.cert = ret['root-ca']
|
||||
}
|
||||
}
|
||||
3
frontend/projects/setup-wizard/src/app/util/misc.util.ts
Normal file
3
frontend/projects/setup-wizard/src/app/util/misc.util.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const pauseFor = (ms: number) => {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// This file can be replaced during build by using the `fileReplacements` array.
|
||||
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
|
||||
// The list of file replacements can be found in `angular.json`.
|
||||
|
||||
export const environment = {
|
||||
production: false,
|
||||
}
|
||||
|
||||
/*
|
||||
* For easier debugging in development mode, you can import the following file
|
||||
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
|
||||
*
|
||||
* This import should be commented out in production mode because it will have a negative impact
|
||||
* on performance if an error is thrown.
|
||||
*/
|
||||
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.
|
||||
23
frontend/projects/setup-wizard/src/index.html
Normal file
23
frontend/projects/setup-wizard/src/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Embassy Setup</title>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
<script>
|
||||
var global = window;
|
||||
</script>
|
||||
<link rel="icon" type="image/x-icon" href="assets/icon/favicon.ico"/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
12
frontend/projects/setup-wizard/src/main.ts
Normal file
12
frontend/projects/setup-wizard/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { enableProdMode } from '@angular/core'
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
|
||||
|
||||
import { AppModule } from './app/app.module'
|
||||
import { environment } from './environments/environment'
|
||||
|
||||
if (environment.production) {
|
||||
enableProdMode()
|
||||
}
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.log(err))
|
||||
65
frontend/projects/setup-wizard/src/polyfills.ts
Normal file
65
frontend/projects/setup-wizard/src/polyfills.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* This file includes polyfills needed by Angular and is loaded before the app.
|
||||
* You can add your own extra polyfills to this file.
|
||||
*
|
||||
* This file is divided into 2 sections:
|
||||
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
|
||||
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
|
||||
* file.
|
||||
*
|
||||
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
|
||||
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
|
||||
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
|
||||
*
|
||||
* Learn more in https://angular.io/guide/browser-support
|
||||
*/
|
||||
|
||||
/***************************************************************************************************
|
||||
* BROWSER POLYFILLS
|
||||
*/
|
||||
|
||||
/** IE11 requires the following for NgClass support on SVG elements */
|
||||
// import 'classlist.js'; // Run `npm install --save classlist.js`.
|
||||
|
||||
/**
|
||||
* Web Animations `@angular/platform-browser/animations`
|
||||
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
|
||||
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
|
||||
*/
|
||||
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
|
||||
|
||||
/**
|
||||
* By default, zone.js will patch all possible macroTask and DomEvents
|
||||
* user can disable parts of macroTask/DomEvents patch by setting following flags
|
||||
* because those flags need to be set before `zone.js` being loaded, and webpack
|
||||
* will put import in the top of bundle, so user need to create a separate file
|
||||
* in this directory (for example: zone-flags.ts), and put the following flags
|
||||
* into that file, and then add the following code before importing zone.js.
|
||||
* import './zone-flags';
|
||||
*
|
||||
* The flags allowed in zone-flags.ts are listed here.
|
||||
*
|
||||
* The following flags will work for all browsers.
|
||||
*
|
||||
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
|
||||
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
|
||||
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
|
||||
*
|
||||
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
|
||||
* with the following flag, it will bypass `zone.js` patch for IE/Edge
|
||||
*
|
||||
* (window as any).__Zone_enable_cross_context_check = true;
|
||||
*
|
||||
*/
|
||||
|
||||
import './zone-flags'
|
||||
|
||||
/***************************************************************************************************
|
||||
* Zone JS is required by default for Angular itself.
|
||||
*/
|
||||
import 'zone.js/dist/zone' // Included with Angular CLI.
|
||||
|
||||
|
||||
/***************************************************************************************************
|
||||
* APPLICATION IMPORTS
|
||||
*/
|
||||
123
frontend/projects/setup-wizard/src/styles.scss
Normal file
123
frontend/projects/setup-wizard/src/styles.scss
Normal file
@@ -0,0 +1,123 @@
|
||||
@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: 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');
|
||||
}
|
||||
|
||||
/** Ionic CSS Variables overrides **/
|
||||
:root {
|
||||
--ion-font-family: 'Benton Sans';
|
||||
}
|
||||
|
||||
ion-content {
|
||||
--background: var(--ion-color-medium);
|
||||
}
|
||||
|
||||
ion-grid {
|
||||
padding-top: 32px;
|
||||
height: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ion-row {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--color: var(--ion-color-light);
|
||||
}
|
||||
|
||||
ion-toolbar {
|
||||
--ion-background-color: var(--ion-color-light);
|
||||
ion-title {
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
}
|
||||
|
||||
ion-avatar {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--highlight-color-valid: transparent;
|
||||
--highlight-color-invalid: transparent;
|
||||
|
||||
--border-radius: 4px;
|
||||
}
|
||||
|
||||
ion-card-title {
|
||||
margin: 16px 0;
|
||||
font-family: 'Montserrat';
|
||||
font-size: x-large;
|
||||
--color: var(--ion-color-light);
|
||||
}
|
||||
|
||||
ion-toast {
|
||||
--background: var(--ion-color-light);
|
||||
--button-color: var(--ion-color-dark);
|
||||
--border-style: solid;
|
||||
--border-width: 1px;
|
||||
--color: white;
|
||||
}
|
||||
|
||||
.center-spinner {
|
||||
height: 20vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inline {
|
||||
* {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
6
frontend/projects/setup-wizard/src/zone-flags.ts
Normal file
6
frontend/projects/setup-wizard/src/zone-flags.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Prevents Angular change detection from
|
||||
* running with certain Web Component callbacks
|
||||
*/
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
(window as any).__Zone_disable_customElements = true
|
||||
9
frontend/projects/setup-wizard/tsconfig.app.json
Normal file
9
frontend/projects/setup-wizard/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./"
|
||||
},
|
||||
"files": ["src/main.ts", "src/polyfills.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
7
frontend/projects/shared/ng-package.json
Normal file
7
frontend/projects/shared/ng-package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/shared",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user