mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 20:14:49 +00:00
Merge pull request #2714 from Start9Labs/sideload-and-backups
fix: implement back sideload and server selection in restoring
This commit is contained in:
@@ -30,15 +30,9 @@ import { MarketplacePkg, StoreIdentity } from '../../../types'
|
|||||||
<!-- background darkening overlay -->
|
<!-- background darkening overlay -->
|
||||||
<div class="dark-overlay"></div>
|
<div class="dark-overlay"></div>
|
||||||
<div class="inner-container-title">
|
<div class="inner-container-title">
|
||||||
<h2 ticker>
|
<h2 ticker>{{ pkg.title }}</h2>
|
||||||
{{ pkg.title }}
|
<h3>{{ pkg.version }}</h3>
|
||||||
</h2>
|
<p>{{ pkg.description.short }}</p>
|
||||||
<h3>
|
|
||||||
{{ pkg.version }}
|
|
||||||
</h3>
|
|
||||||
<p>
|
|
||||||
{{ pkg.description.short }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- control buttons -->
|
<!-- control buttons -->
|
||||||
<ng-content />
|
<ng-content />
|
||||||
@@ -150,8 +144,8 @@ import { MarketplacePkg, StoreIdentity } from '../../../types'
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
top: 0px;
|
top: 0;
|
||||||
left: 0px;
|
left: 0;
|
||||||
border-radius: 1.5rem;
|
border-radius: 1.5rem;
|
||||||
background-color: rgb(63 63 70);
|
background-color: rgb(63 63 70);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
@@ -164,8 +158,15 @@ import { MarketplacePkg, StoreIdentity } from '../../../types'
|
|||||||
imports: [CommonModule, SharedPipesModule, TickerModule, TuiLet],
|
imports: [CommonModule, SharedPipesModule, TickerModule, TuiLet],
|
||||||
})
|
})
|
||||||
export class MarketplacePackageHeroComponent {
|
export class MarketplacePackageHeroComponent {
|
||||||
|
// @TODO Matt this used to be MarketplacePkg
|
||||||
@Input({ required: true })
|
@Input({ required: true })
|
||||||
pkg!: MarketplacePkg
|
pkg!: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
version: string
|
||||||
|
description: { short: string }
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
private readonly marketplaceService = inject(AbstractMarketplaceService)
|
private readonly marketplaceService = inject(AbstractMarketplaceService)
|
||||||
readonly marketplace$ = this.marketplaceService.getSelectedHost$()
|
readonly marketplace$ = this.marketplaceService.getSelectedHost$()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { StartOSDiskInfo } from '../types/api'
|
|||||||
template: `
|
template: `
|
||||||
<tui-icon icon="@tui.save" />
|
<tui-icon icon="@tui.save" />
|
||||||
<span tuiTitle>
|
<span tuiTitle>
|
||||||
<strong>{{ server().hostname }}.local</strong>
|
<strong>{{ server().hostname.replace('.local', '') }}.local</strong>
|
||||||
<span tuiSubtitle>
|
<span tuiSubtitle>
|
||||||
<b>StartOS Version</b>
|
<b>StartOS Version</b>
|
||||||
: {{ server().version }}
|
: {{ server().version }}
|
||||||
@@ -26,7 +26,5 @@ import { StartOSDiskInfo } from '../types/api'
|
|||||||
imports: [DatePipe, TuiIcon, TuiTitle],
|
imports: [DatePipe, TuiIcon, TuiTitle],
|
||||||
})
|
})
|
||||||
export class ServerComponent {
|
export class ServerComponent {
|
||||||
private readonly dialogs = inject(TuiDialogService)
|
|
||||||
|
|
||||||
readonly server = input.required<StartOSDiskInfo>()
|
readonly server = input.required<StartOSDiskInfo>()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { tuiPure } from '@taiga-ui/cdk'
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FormUnionComponent implements OnChanges {
|
export class FormUnionComponent implements OnChanges {
|
||||||
@Input()
|
@Input({ required: true })
|
||||||
spec!: CT.ValueSpecUnion
|
spec!: CT.ValueSpecUnion
|
||||||
|
|
||||||
selectSpec!: CT.ValueSpecSelect
|
selectSpec!: CT.ValueSpecSelect
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TuiCell } from '@taiga-ui/layout'
|
|||||||
import { TuiTitle, TuiButton } from '@taiga-ui/core'
|
import { TuiTitle, TuiButton } from '@taiga-ui/core'
|
||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
import { CopyService, ExverPipesModule } from '@start9labs/shared'
|
import { CopyService } from '@start9labs/shared'
|
||||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||||
import { PatchDB } from 'patch-db-client'
|
import { PatchDB } from 'patch-db-client'
|
||||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
@@ -50,7 +50,7 @@ import { ConfigService } from 'src/app/services/config.service'
|
|||||||
styles: ['[tuiCell] { padding-inline: 0 }'],
|
styles: ['[tuiCell] { padding-inline: 0 }'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ExverPipesModule, TuiTitle, TuiButton, TuiCell],
|
imports: [CommonModule, TuiTitle, TuiButton, TuiCell],
|
||||||
})
|
})
|
||||||
export class AboutComponent {
|
export class AboutComponent {
|
||||||
readonly server$ = inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo')
|
readonly server$ = inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo')
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||||
import { ExverPipesModule } from '@start9labs/shared'
|
|
||||||
import { TuiIcon } from '@taiga-ui/core'
|
import { TuiIcon } from '@taiga-ui/core'
|
||||||
import { DependencyInfo } from '../types/dependency-info'
|
import { DependencyInfo } from '../types/dependency-info'
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ import { DependencyInfo } from '../types/dependency-info'
|
|||||||
],
|
],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ExverPipesModule, TuiIcon],
|
imports: [TuiIcon],
|
||||||
})
|
})
|
||||||
export class ServiceDependencyComponent {
|
export class ServiceDependencyComponent {
|
||||||
@Input({ required: true, alias: 'serviceDependency' })
|
@Input({ required: true, alias: 'serviceDependency' })
|
||||||
|
|||||||
@@ -21,14 +21,12 @@ import { BackupType } from '../types/backup-type'
|
|||||||
imports: [TuiIcon],
|
imports: [TuiIcon],
|
||||||
})
|
})
|
||||||
export class BackupsStatusComponent {
|
export class BackupsStatusComponent {
|
||||||
private readonly exver = inject(Exver)
|
@Input({ required: true }) hasBackup!: boolean
|
||||||
|
|
||||||
@Input({ required: true }) serverId!: string
|
|
||||||
@Input({ required: true }) type!: BackupType
|
@Input({ required: true }) type!: BackupType
|
||||||
@Input({ required: true }) target!: BackupTarget
|
@Input({ required: true }) mountable!: boolean
|
||||||
|
|
||||||
get status() {
|
get status() {
|
||||||
if (!this.target.mountable) {
|
if (!this.mountable) {
|
||||||
return {
|
return {
|
||||||
icon: '@tui.bar-chart',
|
icon: '@tui.bar-chart',
|
||||||
color: 'var(--tui-text-negative)',
|
color: 'var(--tui-text-negative)',
|
||||||
@@ -46,28 +44,16 @@ export class BackupsStatusComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.hasBackup) {
|
return this.hasBackup
|
||||||
return {
|
? {
|
||||||
icon: '@tui.cloud',
|
icon: '@tui.cloud',
|
||||||
color: 'var(--tui-text-positive)',
|
color: 'var(--tui-text-positive)',
|
||||||
text: 'Embassy backup detected',
|
text: 'Embassy backup detected',
|
||||||
}
|
}
|
||||||
}
|
: {
|
||||||
|
icon: '@tui.cloud-off',
|
||||||
return {
|
color: 'var(--tui-text-negative)',
|
||||||
icon: '@tui.cloud-off',
|
text: 'No Embassy backup',
|
||||||
color: 'var(--tui-text-negative)',
|
}
|
||||||
text: 'No Embassy backup',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private get hasBackup(): boolean {
|
|
||||||
return (
|
|
||||||
this.target.startOs[this.serverId] &&
|
|
||||||
this.exver.compareOsVersion(
|
|
||||||
this.target.startOs[this.serverId].version,
|
|
||||||
'0.3.6',
|
|
||||||
) !== 'less'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { ErrorService } from '@start9labs/shared'
|
import { ErrorService, Exver, isEmptyObject } from '@start9labs/shared'
|
||||||
import {
|
import {
|
||||||
TuiButton,
|
TuiButton,
|
||||||
TuiDialogContext,
|
TuiDialogContext,
|
||||||
@@ -45,8 +45,8 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
|
|||||||
<strong>{{ displayInfo.name }}</strong>
|
<strong>{{ displayInfo.name }}</strong>
|
||||||
<backups-status
|
<backups-status
|
||||||
[type]="context.data.type"
|
[type]="context.data.type"
|
||||||
[target]="target"
|
[mountable]="target.mountable"
|
||||||
[serverId]="serverId"
|
[hasBackup]="hasBackup(target)"
|
||||||
/>
|
/>
|
||||||
<div [style.color]="'var(--tui-text-secondary'">
|
<div [style.color]="'var(--tui-text-secondary'">
|
||||||
{{ displayInfo.description }}
|
{{ displayInfo.description }}
|
||||||
@@ -73,6 +73,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class BackupsTargetModal {
|
export class BackupsTargetModal {
|
||||||
|
private readonly exver = inject(Exver)
|
||||||
private readonly dialogs = inject(TuiDialogService)
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
private readonly errorService = inject(ErrorService)
|
private readonly errorService = inject(ErrorService)
|
||||||
private readonly api = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
@@ -106,7 +107,17 @@ export class BackupsTargetModal {
|
|||||||
isDisabled(target: BackupTarget): boolean {
|
isDisabled(target: BackupTarget): boolean {
|
||||||
return (
|
return (
|
||||||
!target.mountable ||
|
!target.mountable ||
|
||||||
(this.context.data.type === 'restore' && !target.startOs)
|
(this.context.data.type === 'restore' && !this.hasBackup(target))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasBackup(target: BackupTarget): boolean {
|
||||||
|
return (
|
||||||
|
target.startOs?.[this.serverId] &&
|
||||||
|
this.exver.compareOsVersion(
|
||||||
|
target.startOs[this.serverId].version,
|
||||||
|
'0.3.6',
|
||||||
|
) !== 'less'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,22 +43,17 @@ export class BackupsRestoreService {
|
|||||||
this.dialogs
|
this.dialogs
|
||||||
.open<BackupTarget>(TARGET, TARGET_RESTORE)
|
.open<BackupTarget>(TARGET, TARGET_RESTORE)
|
||||||
.pipe(
|
.pipe(
|
||||||
// @TODO Alex implement servers
|
|
||||||
switchMap(target =>
|
switchMap(target =>
|
||||||
this.dialogs
|
this.dialogs
|
||||||
.open<StartOSDiskInfo & { id: string }>(SERVERS, {
|
.open<StartOSDiskInfo & { id: string }>(SERVERS, {
|
||||||
data: { servers: [] },
|
label: 'Select server',
|
||||||
|
data: { servers: Object.values(target.startOs) },
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(({ id, passwordHash }) =>
|
switchMap(({ id, passwordHash }) =>
|
||||||
this.dialogs.open<string>(PROMPT, PROMPT_OPTIONS).pipe(
|
this.dialogs.open<string>(PROMPT, PROMPT_OPTIONS).pipe(
|
||||||
exhaustMap(password =>
|
exhaustMap(password =>
|
||||||
this.getRecoverData(
|
this.getRecoverData(target.id, id, password, passwordHash),
|
||||||
target.id,
|
|
||||||
id,
|
|
||||||
password,
|
|
||||||
passwordHash || '',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
take(1),
|
take(1),
|
||||||
switchMap(data =>
|
switchMap(data =>
|
||||||
@@ -81,10 +76,10 @@ export class BackupsRestoreService {
|
|||||||
targetId: string,
|
targetId: string,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
password: string,
|
password: string,
|
||||||
hash: string,
|
hash: string | null,
|
||||||
): Observable<RecoverData> {
|
): Observable<RecoverData> {
|
||||||
return of(password).pipe(
|
return of(password).pipe(
|
||||||
tap(() => argon2.verify(hash, password)),
|
tap(() => argon2.verify(hash || '', password)),
|
||||||
switchMap(() => {
|
switchMap(() => {
|
||||||
const loader = this.loader.open('Decrypting drive...').subscribe()
|
const loader = this.loader.open('Decrypting drive...').subscribe()
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
|
|||||||
export class MetricsService extends Observable<ServerMetrics> {
|
export class MetricsService extends Observable<ServerMetrics> {
|
||||||
private readonly api = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
|
|
||||||
// @TODO Alex do we need to use defer? I am unsure when this is necessary.
|
|
||||||
private readonly metrics$ = defer(() =>
|
private readonly metrics$ = defer(() =>
|
||||||
this.api.followServerMetrics({}),
|
this.api.followServerMetrics({}),
|
||||||
).pipe(
|
).pipe(
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { TuiLet } from '@taiga-ui/cdk'
|
|
||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { Component, inject, Input } from '@angular/core'
|
import { Component, inject, Input } from '@angular/core'
|
||||||
import { Router, RouterLink } from '@angular/router'
|
import { Router, RouterLink } from '@angular/router'
|
||||||
@@ -7,20 +6,21 @@ import {
|
|||||||
AdditionalModule,
|
AdditionalModule,
|
||||||
MarketplaceDependenciesComponent,
|
MarketplaceDependenciesComponent,
|
||||||
MarketplacePackageHeroComponent,
|
MarketplacePackageHeroComponent,
|
||||||
MarketplacePkg,
|
|
||||||
} from '@start9labs/marketplace'
|
} from '@start9labs/marketplace'
|
||||||
import {
|
import {
|
||||||
Exver,
|
|
||||||
ErrorService,
|
ErrorService,
|
||||||
|
Exver,
|
||||||
LoadingService,
|
LoadingService,
|
||||||
SharedPipesModule,
|
SharedPipesModule,
|
||||||
} from '@start9labs/shared'
|
} from '@start9labs/shared'
|
||||||
|
import { T } from '@start9labs/start-sdk'
|
||||||
|
import { TuiLet } from '@taiga-ui/cdk'
|
||||||
import { TuiAlertService, TuiButton } from '@taiga-ui/core'
|
import { TuiAlertService, TuiButton } from '@taiga-ui/core'
|
||||||
import { PatchDB } from 'patch-db-client'
|
import { PatchDB } from 'patch-db-client'
|
||||||
import { combineLatest, map } from 'rxjs'
|
import { combineLatest, map } from 'rxjs'
|
||||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
import { ClientStorageService } from 'src/app/services/client-storage.service'
|
import { ClientStorageService } from 'src/app/services/client-storage.service'
|
||||||
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
import { getManifest } from 'src/app/utils/get-package-data'
|
import { getManifest } from 'src/app/utils/get-package-data'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -33,24 +33,26 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
|||||||
[pkg]="package"
|
[pkg]="package"
|
||||||
>
|
>
|
||||||
<div class="inner-container">
|
<div class="inner-container">
|
||||||
<a
|
@if (button !== null && button !== 'Install') {
|
||||||
*ngIf="button !== null && button !== 'Install'"
|
<a
|
||||||
tuiButton
|
tuiButton
|
||||||
appearance="tertiary-solid"
|
appearance="tertiary-solid"
|
||||||
[routerLink]="'/portal/service/' + package.id"
|
[routerLink]="'/portal/service/' + package.id"
|
||||||
>
|
>
|
||||||
View installed
|
View installed
|
||||||
</a>
|
</a>
|
||||||
<button *ngIf="button" tuiButton (click)="upload()">
|
}
|
||||||
{{ button }}
|
@if (button) {
|
||||||
</button>
|
<button tuiButton (click)="upload()">{{ button }}</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</marketplace-package-hero>
|
</marketplace-package-hero>
|
||||||
<marketplace-about [pkg]="package" />
|
<!-- @TODO Matt do we want this here? How do we turn s9pk into MarketplacePkg? -->
|
||||||
@if (!(package.dependencyMetadata | empty)) {
|
<!-- <marketplace-about [pkg]="package" />-->
|
||||||
<marketplace-dependencies [pkg]="package" (open)="open($event)" />
|
<!-- @if (!(package.dependencyMetadata | empty)) {-->
|
||||||
}
|
<!-- <marketplace-dependencies [pkg]="package" (open)="open($event)" />-->
|
||||||
<marketplace-additional [pkg]="package" />
|
<!-- }-->
|
||||||
|
<!-- <marketplace-additional [pkg]="package" />-->
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [
|
styles: [
|
||||||
@@ -125,7 +127,7 @@ export class SideloadPackageComponent {
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Input({ required: true })
|
@Input({ required: true })
|
||||||
package!: MarketplacePkg
|
package!: T.Manifest & { icon: string }
|
||||||
|
|
||||||
@Input({ required: true })
|
@Input({ required: true })
|
||||||
file!: File
|
file!: File
|
||||||
|
|||||||
@@ -1,38 +1,39 @@
|
|||||||
import { CommonModule } from '@angular/common'
|
import {
|
||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { MarketplacePkg } from '@start9labs/marketplace'
|
import { T } from '@start9labs/start-sdk'
|
||||||
|
import { tuiIsString } from '@taiga-ui/cdk'
|
||||||
import { TuiButton, TuiLink } from '@taiga-ui/core'
|
import { TuiButton, TuiLink } from '@taiga-ui/core'
|
||||||
import {
|
import {
|
||||||
TuiAvatar,
|
TuiAvatar,
|
||||||
TuiFiles,
|
TuiFiles,
|
||||||
tuiInputFilesOptionsProvider,
|
tuiInputFilesOptionsProvider,
|
||||||
} from '@taiga-ui/kit'
|
} from '@taiga-ui/kit'
|
||||||
import { Subject } from 'rxjs'
|
|
||||||
import { ConfigService } from 'src/app/services/config.service'
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
import { SideloadPackageComponent } from './package.component'
|
import { SideloadPackageComponent } from './package.component'
|
||||||
|
import { parseS9pk } from './sideload.utils'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
<ng-container *ngIf="refresh$ | async"></ng-container>
|
@if (file && package()) {
|
||||||
<sideload-package
|
<sideload-package [package]="package()!" [file]="file!">
|
||||||
*ngIf="package && file; else upload"
|
<button
|
||||||
[package]="package"
|
tuiIconButton
|
||||||
[file]="file"
|
appearance="secondary"
|
||||||
>
|
iconStart="@tui.x"
|
||||||
<button
|
[style.border-radius.%]="100"
|
||||||
tuiIconButton
|
[style.justify-self]="'end'"
|
||||||
appearance="secondary"
|
(click)="clear()"
|
||||||
iconStart="@tui.x"
|
>
|
||||||
[style.border-radius.%]="100"
|
Close
|
||||||
[style.float]="'right'"
|
</button>
|
||||||
(click)="clear()"
|
</sideload-package>
|
||||||
class="justify-self-end"
|
} @else {
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</sideload-package>
|
|
||||||
<ng-template #upload>
|
|
||||||
<label tuiInputFiles (click)="clear()">
|
<label tuiInputFiles (click)="clear()">
|
||||||
<input
|
<input
|
||||||
tuiInputFiles
|
tuiInputFiles
|
||||||
@@ -41,26 +42,25 @@ import { SideloadPackageComponent } from './package.component'
|
|||||||
(ngModelChange)="onFile($event)"
|
(ngModelChange)="onFile($event)"
|
||||||
/>
|
/>
|
||||||
<ng-template>
|
<ng-template>
|
||||||
<div *ngIf="invalid; else valid">
|
@if (error()) {
|
||||||
<tui-avatar appearance="secondary" src="@tui.circle-x" />
|
<div>
|
||||||
<p [style.color]="'var(--tui-text-negative)'">
|
<tui-avatar appearance="secondary" src="@tui.circle-x" />
|
||||||
Invalid package file
|
<p class="g-error">{{ error() }}</p>
|
||||||
</p>
|
<button tuiButton>Try again</button>
|
||||||
<button tuiButton>Try again</button>
|
</div>
|
||||||
</div>
|
} @else {
|
||||||
<ng-template #valid>
|
|
||||||
<div>
|
<div>
|
||||||
<tui-avatar appearance="secondary" src="@tui.cloud-upload" />
|
<tui-avatar appearance="secondary" src="@tui.cloud-upload" />
|
||||||
<p>Upload .s9pk package file</p>
|
<p>Upload .s9pk package file</p>
|
||||||
<p *ngIf="isTor" [style.color]="'var(--tui-text-positive)'">
|
@if (isTor) {
|
||||||
Tip: switch to LAN for faster uploads
|
<p class="g-success">Tip: switch to LAN for faster uploads</p>
|
||||||
</p>
|
}
|
||||||
<button tuiButton>Upload</button>
|
<button tuiButton>Upload</button>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</label>
|
</label>
|
||||||
</ng-template>
|
}
|
||||||
`,
|
`,
|
||||||
host: { class: 'g-page', '[style.padding-top.rem]': '2' },
|
host: { class: 'g-page', '[style.padding-top.rem]': '2' },
|
||||||
styles: [
|
styles: [
|
||||||
@@ -80,7 +80,6 @@ import { SideloadPackageComponent } from './package.component'
|
|||||||
providers: [tuiInputFilesOptionsProvider({ maxFileSize: Infinity })],
|
providers: [tuiInputFilesOptionsProvider({ maxFileSize: Infinity })],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
FormsModule,
|
||||||
TuiFiles,
|
TuiFiles,
|
||||||
TuiLink,
|
TuiLink,
|
||||||
@@ -90,28 +89,26 @@ import { SideloadPackageComponent } from './package.component'
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export default class SideloadComponent {
|
export default class SideloadComponent {
|
||||||
readonly refresh$ = new Subject<void>()
|
private readonly cdr = inject(ChangeDetectorRef)
|
||||||
readonly isTor = inject(ConfigService).isTor()
|
readonly isTor = inject(ConfigService).isTor()
|
||||||
|
|
||||||
invalid = false
|
|
||||||
file: File | null = null
|
file: File | null = null
|
||||||
package: MarketplacePkg | null = null
|
readonly package = signal<(T.Manifest & { icon: string }) | null>(null)
|
||||||
|
readonly error = signal('')
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.invalid = false
|
|
||||||
this.file = null
|
this.file = null
|
||||||
this.package = null
|
this.package.set(null)
|
||||||
|
this.error.set('')
|
||||||
}
|
}
|
||||||
|
|
||||||
// @TODO Alex refactor sideload
|
|
||||||
async onFile(file: File | null) {
|
async onFile(file: File | null) {
|
||||||
// if (!file || !(await validateS9pk(file))) {
|
const parsed = file ? await parseS9pk(file) : ''
|
||||||
// this.invalid = true
|
|
||||||
// } else {
|
|
||||||
// this.package = await parseS9pk(file)
|
|
||||||
// this.file = file
|
|
||||||
// }
|
|
||||||
|
|
||||||
this.refresh$.next()
|
this.file = file
|
||||||
|
this.package.set(tuiIsString(parsed) ? null : parsed)
|
||||||
|
this.error.set(tuiIsString(parsed) ? parsed : '')
|
||||||
|
// @TODO Alex figure out why it is needed even though we use signals
|
||||||
|
this.cdr.markForCheck()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { S9pk, T } from '@start9labs/start-sdk'
|
||||||
|
import cbor from 'cbor'
|
||||||
|
|
||||||
|
const MAGIC = new Uint8Array([59, 59])
|
||||||
|
const VERSION_1 = new Uint8Array([1])
|
||||||
|
const VERSION_2 = new Uint8Array([2])
|
||||||
|
|
||||||
|
interface Positions {
|
||||||
|
[key: string]: [bigint, bigint] // [position, length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseS9pk(
|
||||||
|
file: File,
|
||||||
|
): Promise<(T.Manifest & { icon: string }) | string> {
|
||||||
|
const magic = new Uint8Array(await blobToBuffer(file.slice(0, 2)))
|
||||||
|
const version = new Uint8Array(await blobToBuffer(file.slice(2, 3)))
|
||||||
|
|
||||||
|
if (compare(magic, MAGIC)) {
|
||||||
|
try {
|
||||||
|
if (compare(version, VERSION_1)) {
|
||||||
|
return await parseS9pkV1(file)
|
||||||
|
} else if (compare(version, VERSION_2)) {
|
||||||
|
const s9pk = await S9pk.deserialize(file, null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...s9pk.manifest,
|
||||||
|
icon: await s9pk.icon(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(version)
|
||||||
|
|
||||||
|
return 'Invalid package file'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
|
||||||
|
return e instanceof Error
|
||||||
|
? `Invalid package file: ${e.message}`
|
||||||
|
: 'Invalid package file'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Invalid package file'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseS9pkV1(file: File) {
|
||||||
|
const positions: Positions = {}
|
||||||
|
// magic=2bytes, version=1bytes, pubkey=32bytes, signature=64bytes, toc_length=4bytes = 103byte is starting point
|
||||||
|
let start = 103
|
||||||
|
let end = start + 1 // 104
|
||||||
|
const tocLength = new DataView(
|
||||||
|
await blobToBuffer(file.slice(99, 103) ?? new Blob()),
|
||||||
|
).getUint32(0, false)
|
||||||
|
await getPositions(start, end, file, positions, tocLength as any)
|
||||||
|
|
||||||
|
const data = await blobToBuffer(
|
||||||
|
file.slice(
|
||||||
|
Number(positions['manifest'][0]),
|
||||||
|
Number(positions['manifest'][0]) + Number(positions['manifest'][1]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(await cbor.decode(data, true)),
|
||||||
|
icon: await blobToDataURL(
|
||||||
|
file.slice(
|
||||||
|
Number(positions['icon'][0]),
|
||||||
|
Number(positions['icon'][0]) + Number(positions['icon'][1]),
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPositions(
|
||||||
|
initialStart: number,
|
||||||
|
initialEnd: number,
|
||||||
|
file: Blob,
|
||||||
|
positions: Positions,
|
||||||
|
tocLength: number,
|
||||||
|
) {
|
||||||
|
let start = initialStart
|
||||||
|
let end = initialEnd
|
||||||
|
const titleLength = new Uint8Array(
|
||||||
|
await blobToBuffer(file.slice(start, end)),
|
||||||
|
)[0]
|
||||||
|
const tocTitle = await file.slice(end, end + titleLength).text()
|
||||||
|
start = end + titleLength
|
||||||
|
end = start + 8
|
||||||
|
const chapterPosition = new DataView(
|
||||||
|
await blobToBuffer(file.slice(start, end)),
|
||||||
|
).getBigUint64(0, false)
|
||||||
|
start = end
|
||||||
|
end = start + 8
|
||||||
|
const chapterLength = new DataView(
|
||||||
|
await blobToBuffer(file.slice(start, end)),
|
||||||
|
).getBigUint64(0, false)
|
||||||
|
|
||||||
|
positions[tocTitle] = [chapterPosition, chapterLength]
|
||||||
|
start = end
|
||||||
|
end = start + 1
|
||||||
|
if (end <= tocLength + (initialStart - 1)) {
|
||||||
|
await getPositions(start, end, file, positions, tocLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBlobAsDataURL(
|
||||||
|
f: Blob | File,
|
||||||
|
): Promise<string | ArrayBuffer | null> {
|
||||||
|
const reader = new FileReader()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
reader.onloadend = () => {
|
||||||
|
resolve(reader.result)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(f)
|
||||||
|
reader.onerror = _ => reject(new Error('error reading blob'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobToDataURL(data: Blob | File): Promise<string> {
|
||||||
|
const res = await readBlobAsDataURL(data)
|
||||||
|
if (res instanceof ArrayBuffer) {
|
||||||
|
throw new Error('readBlobAsDataURL response should not be an array buffer')
|
||||||
|
}
|
||||||
|
if (res == null) {
|
||||||
|
throw new Error('readBlobAsDataURL response should not be null')
|
||||||
|
}
|
||||||
|
if (typeof res === 'string') return res
|
||||||
|
throw new Error('no possible blob to data url resolution found')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobToBuffer(data: Blob | File): Promise<ArrayBuffer> {
|
||||||
|
const res = await readBlobToArrayBuffer(data)
|
||||||
|
if (res instanceof String) {
|
||||||
|
throw new Error('readBlobToArrayBuffer response should not be a string')
|
||||||
|
}
|
||||||
|
if (res == null) {
|
||||||
|
throw new Error('readBlobToArrayBuffer response should not be null')
|
||||||
|
}
|
||||||
|
if (res instanceof ArrayBuffer) return res
|
||||||
|
throw new Error('no possible blob to array buffer resolution found')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBlobToArrayBuffer(
|
||||||
|
f: Blob | File,
|
||||||
|
): Promise<string | ArrayBuffer | null> {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
reader.onloadend = () => {
|
||||||
|
resolve(reader.result)
|
||||||
|
}
|
||||||
|
reader.readAsArrayBuffer(f)
|
||||||
|
reader.onerror = _ => reject(new Error('error reading blob'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function compare(a: Uint8Array, b: Uint8Array) {
|
||||||
|
return a.length === b.length && a.every((value, index) => value === b[index])
|
||||||
|
}
|
||||||
@@ -1014,7 +1014,7 @@ export module Mock {
|
|||||||
hostname: 'smb://192.169.10.0',
|
hostname: 'smb://192.169.10.0',
|
||||||
path: '/Desktop/embassy-backups',
|
path: '/Desktop/embassy-backups',
|
||||||
username: 'TestUser',
|
username: 'TestUser',
|
||||||
mountable: false,
|
mountable: true,
|
||||||
startOs: {
|
startOs: {
|
||||||
abcdefgh: {
|
abcdefgh: {
|
||||||
hostname: 'adjective-noun.local',
|
hostname: 'adjective-noun.local',
|
||||||
@@ -1032,7 +1032,7 @@ export module Mock {
|
|||||||
name: 'Dropbox 1',
|
name: 'Dropbox 1',
|
||||||
provider: 'dropbox',
|
provider: 'dropbox',
|
||||||
path: '/Home/backups',
|
path: '/Home/backups',
|
||||||
mountable: true,
|
mountable: false,
|
||||||
startOs: {},
|
startOs: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user