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:
Aiden McClelland
2022-01-31 14:01:33 -07:00
committed by GitHub
parent 7e6c852ebd
commit 574539faec
504 changed files with 11569 additions and 78972 deletions

View File

@@ -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 { }

View File

@@ -0,0 +1,3 @@
<ion-app>
<ion-router-outlet></ion-router-outlet>
</ion-app>

View 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() {}
}

View 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 {}

View File

@@ -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 {}

View File

@@ -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 {}

View File

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

View File

@@ -0,0 +1,5 @@
.code-block {
background-color: rgb(69, 69, 69);
padding: 12px;
margin-bottom: 32px;
}

View File

@@ -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()
}
}

View File

@@ -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 { }

View File

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

View 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()
}
}

View File

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

View File

@@ -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,
})
}
}

View File

@@ -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 *****',
},
]

View File

@@ -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()
}
}
}

View File

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

View File

@@ -0,0 +1,3 @@
export function pauseFor (ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

View File

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

View File

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

View 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>

View 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));

View 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
*/

View 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;
}

View 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

View 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"]
}

View 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 { }

View File

@@ -0,0 +1,3 @@
<ion-app>
<ion-router-outlet></ion-router-outlet>
</ion-app>

View 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}`)
}
}
}

View 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 {}

View 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
}
}
}

View File

@@ -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 { }

View File

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

View File

@@ -0,0 +1,3 @@
ion-content {
--ion-text-color: var(--ion-color-dark);
}

View File

@@ -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()
}
}

View File

@@ -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 { }

View File

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

View File

@@ -0,0 +1,3 @@
ion-content {
--ion-text-color: var(--ion-color-dark);
}

View File

@@ -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()
}
}

View File

@@ -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 { }

View File

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

View File

@@ -0,0 +1,3 @@
ion-content {
--ion-text-color: var(--ion-color-dark);
}

View File

@@ -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()
}
}

View File

@@ -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 { }

View File

@@ -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 { }

View File

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

View File

@@ -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()
}
}
}

View File

@@ -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 { }

View File

@@ -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 { }

View File

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

View File

@@ -0,0 +1,9 @@
import { Component } from '@angular/core'
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage { }

View File

@@ -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 { }

View File

@@ -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 { }

View File

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

View File

@@ -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)
}
}

View File

@@ -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 { }

View File

@@ -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 { }

View File

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

View File

@@ -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`)
}
})
}
}

View File

@@ -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 { }

View File

@@ -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 { }

View File

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

View File

@@ -0,0 +1,5 @@
ion-item {
--border-style: solid;
--border-width: 1px;
--border-color: var(--ion-color-medium);
}

View File

@@ -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()
}
}
}

View File

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

View File

@@ -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 { }

View File

@@ -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 { }

View File

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

View File

@@ -0,0 +1,4 @@
.target-label {
font-weight: bold;
padding-bottom: 6px;
}

View File

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

View File

@@ -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 { }

View File

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

View File

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

View File

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

View File

@@ -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]
}
}

View 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 { }

View File

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

View File

@@ -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)
}

View File

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

View File

@@ -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,
},
]

View File

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

View File

@@ -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()
}
}
}

View File

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

View File

@@ -0,0 +1,3 @@
export const pauseFor = (ms: number) => {
return new Promise(resolve => setTimeout(resolve, ms))
}

View File

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

View File

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

View 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>

View 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))

View 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
*/

View 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;
}

View 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

View 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"]
}

View 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