diff --git a/src/app/apps/apps.component.html b/src/app/apps/apps.component.html index dc319023..c6aeab72 100644 --- a/src/app/apps/apps.component.html +++ b/src/app/apps/apps.component.html @@ -1,33 +1,34 @@ -
+
-
-
- -
+
+ @if (dragOver) { +
+
+ Drop .ipk to install +
+
+ }
diff --git a/src/app/apps/apps.component.scss b/src/app/apps/apps.component.scss index e69de29b..738ac7dc 100644 --- a/src/app/apps/apps.component.scss +++ b/src/app/apps/apps.component.scss @@ -0,0 +1,19 @@ +.drop-overlay { + position: absolute; + inset: 0; + background: rgba(13, 110, 253, 0.15); + border: 3px dashed rgba(13, 110, 253, 0.75); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 1050; +} + +.drop-overlay-inner { + padding: 1rem 1.5rem; + background: var(--bs-body-bg); + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + font-size: 1.25rem; +} diff --git a/src/app/apps/apps.component.ts b/src/app/apps/apps.component.ts index 6a17e13c..97cc4021 100644 --- a/src/app/apps/apps.component.ts +++ b/src/app/apps/apps.component.ts @@ -1,17 +1,18 @@ -import {Component, Injector, OnDestroy, OnInit, ViewChild, ChangeDetectionStrategy} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Injector, NgZone, OnDestroy, OnInit, ViewChild} from '@angular/core'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; -import {noop, Observable, Subscription} from 'rxjs'; -import {Device, PackageInfo, RawPackageInfo} from '../types'; +import {noop, Observable, Subject, Subscription} from 'rxjs'; +import {Device, RawPackageInfo} from '../types'; import {AppManagerService, DeviceManagerService, RepositoryItem} from '../core/services'; import {MessageDialogComponent} from '../shared/components/message-dialog/message-dialog.component'; import {ProgressDialogComponent} from '../shared/components/progress-dialog/progress-dialog.component'; -import {keyBy} from 'lodash'; import {open as showOpenDialog} from '@tauri-apps/plugin-dialog'; import {basename, downloadDir} from "@tauri-apps/api/path"; +import * as os from "@tauri-apps/plugin-os"; +import {getCurrentWebview} from "@tauri-apps/api/webview"; import {APP_ID_HBCHANNEL} from "../shared/constants"; import {HbchannelRemoveComponent} from "./hbchannel-remove/hbchannel-remove.component"; -import {StatStorageInfoComponent} from "../shared/components/stat-storage-info/stat-storage-info.component"; -import {DetailsComponent} from "./details/details.component"; + +type UnlistenFn = () => void; @Component({ selector: 'app-apps', @@ -22,57 +23,59 @@ import {DetailsComponent} from "./details/details.component"; }) export class AppsComponent implements OnInit, OnDestroy { - packages$?: Observable; - instPackages?: Record; device: Device | null = null; - devices$?: Observable; + devices$?: Observable; tabId: string = 'installed'; - - @ViewChild('storageInfo') storageInfo?: StatStorageInfoComponent; + dragOver = false; + readonly storageChanged$ = new Subject(); private deviceSubscription?: Subscription; - private packagesSubscription?: Subscription; + private unlistenDragDrop?: UnlistenFn; constructor( - public deviceManager: DeviceManagerService, private modalService: NgbModal, private appManager: AppManagerService, + public deviceManager: DeviceManagerService, + private zone: NgZone, ) { } ngOnInit(): void { this.devices$ = this.deviceManager.devices$; - this.deviceSubscription = this.deviceManager.selected$.subscribe((device) => { - this.device = device; - if (device) { - this.loadPackages(); - } else { - this.packages$ = undefined; - this.packagesSubscription?.unsubscribe(); - this.packagesSubscription = undefined; - } + this.deviceSubscription = this.devices$.subscribe(devices => { + this.device = devices?.find(d => d.default) ?? null; }); + this.setupDragDrop().catch(e => console.warn('Drag-drop listener failed:', e)); } ngOnDestroy(): void { this.deviceSubscription?.unsubscribe(); - this.packagesSubscription?.unsubscribe(); - this.packagesSubscription = undefined; + this.unlistenDragDrop?.(); } - loadPackages(): void { - const device = this.device; - if (!device) return; - this.packagesSubscription?.unsubscribe(); - this.packages$ = this.appManager.packages$(device); - this.packagesSubscription = this.packages$.subscribe({ - next: (pkgs) => { - if (pkgs?.length) { - this.instPackages = keyBy(pkgs, (pkg) => pkg.id); + private async setupDragDrop(): Promise { + if (os.type() === 'android' || os.type() === 'ios') return; + const webview = getCurrentWebview(); + this.unlistenDragDrop = await webview.onDragDropEvent(event => { + this.zone.run(() => { + switch (event.payload.type) { + case 'over': + case 'enter': + this.dragOver = true; + break; + case 'leave': + this.dragOver = false; + break; + case 'drop': + this.dragOver = false; + const ipks = event.payload.paths.filter(p => p.toLowerCase().endsWith('.ipk')); + for (const path of ipks) { + this.installFromPath(path).catch(noop); + } + break; } - }, error: noop + }); }); - this.appManager.load(device).catch(noop); } async openInstallChooser(): Promise { @@ -85,11 +88,17 @@ export class AppsComponent implements OnInit, OnDestroy { if (!path) { return; } + await this.installFromPath(path); + } + + private async installFromPath(path: string): Promise { + if (!this.device) return; const progress = ProgressDialogComponent.open(this.modalService); const component = progress.componentInstance as ProgressDialogComponent; try { await this.appManager.installByPath(this.device, path, (progress, statusText) => component.update(statusText, progress)); + this.storageChanged$.next(); } catch (e) { console.warn(e); this.handleInstallationError(await basename(path), e as Error); @@ -127,7 +136,7 @@ export class AppsComponent implements OnInit, OnDestroy { const progress = ProgressDialogComponent.open(this.modalService); try { await this.appManager.remove(this.device, pkg.id); - this.storageInfo?.refresh(); + this.storageChanged$.next(); return true; } catch (e) { MessageDialogComponent.open(this.modalService, { @@ -141,8 +150,8 @@ export class AppsComponent implements OnInit, OnDestroy { } } - async installPackage(item: RepositoryItem, channel: 'stable' | 'beta' = 'stable'): Promise { - const device = this.device; + async installPackage(item: RepositoryItem, channel: 'stable' | 'beta' = 'stable', deviceOverride?: Device): Promise { + const device = deviceOverride ?? this.device; if (!device) return false; const progress = ProgressDialogComponent.open(this.modalService); try { @@ -181,7 +190,7 @@ export class AppsComponent implements OnInit, OnDestroy { const component = progress.componentInstance as ProgressDialogComponent; await this.appManager.installByManifest(device, manifest, (progress, statusText) => component.update(statusText, progress)); - this.storageInfo?.refresh(); + this.storageChanged$.next(); return true; } catch (e: any) { this.handleInstallationError(item.title, e as Error); @@ -191,21 +200,6 @@ export class AppsComponent implements OnInit, OnDestroy { } } - openDetails(item: RepositoryItem): void { - const modalRef = this.modalService.open(DetailsComponent, { - size: 'lg', - scrollable: true, - injector: Injector.create({ - providers: [ - {provide: RepositoryItem, useValue: item}, - {provide: 'device', useValue: this.device}, - ], - }), - }); - const component = modalRef.componentInstance as DetailsComponent; - component.parent = this; - } - private handleInstallationError(name: string, e: Error) { MessageDialogComponent.open(this.modalService, { title: `Failed to install ${name}`, diff --git a/src/app/apps/apps.module.ts b/src/app/apps/apps.module.ts index 10defa72..eb7a0b46 100644 --- a/src/app/apps/apps.module.ts +++ b/src/app/apps/apps.module.ts @@ -8,6 +8,7 @@ import {InstalledComponent} from "./installed/installed.component"; import {NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbProgressbar} from "@ng-bootstrap/ng-bootstrap"; import {SharedModule} from "../shared/shared.module"; import {HbchannelRemoveComponent} from './hbchannel-remove/hbchannel-remove.component'; +import {DetailsComponent as InstalledDetailsComponent} from "./installed/details/details.component"; import {FormsModule} from "@angular/forms"; @NgModule({ @@ -27,6 +28,7 @@ import {FormsModule} from "@angular/forms"; NgbProgressbar, NgOptimizedImage, FormsModule, + InstalledDetailsComponent, ] }) export class AppsModule { diff --git a/src/app/apps/channel/channel.component.html b/src/app/apps/channel/channel.component.html index 81bc884c..5a669889 100644 --- a/src/app/apps/channel/channel.component.html +++ b/src/app/apps/channel/channel.component.html @@ -1,28 +1,73 @@ -@let repoPage = repoPage$ | async; -@if (repoPage) { -
    - @for (item of repoPage.packages; track item) { -
  • - @let manifest = item.manifest; - @if (manifest) { -
    - -
    -
    {{ item.title }}
    -
    {{ manifest.appDescription }}
    +
    + @let repoPage = repoPage$ | async; + @if (repoPage) { + @if (page === 1 && repoPage.packages.length > 2) { +
  • + + @for (item of repoPage.packages.slice(1, 3); track item.id) { + + } +
+ } - -
- - -
-} @else { - -} +
+
{{ page === 1 ? 'All apps' : ('Page ' + page) }}
+
+ @for (item of repoPage.packages; track item.id) { + @if (item.manifest) { + + } + } +
+
+
+ + +
+ } @else { + + } + diff --git a/src/app/apps/channel/channel.component.scss b/src/app/apps/channel/channel.component.scss index e69de29b..b6c64d03 100644 --- a/src/app/apps/channel/channel.component.scss +++ b/src/app/apps/channel/channel.component.scss @@ -0,0 +1,100 @@ +:host { + display: block; + height: 100%; + width: 100%; + overflow: auto; +} + +.featured-grid { + display: grid; + grid-template-columns: 2fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 0.75rem; +} + +.app-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 0.75rem; +} + +.app-card { + position: relative; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem; + background: transparent; + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + text-align: left; + transition: background 0.12s ease, border-color 0.12s ease; + + &:hover { + background: var(--bs-tertiary-bg); + border-color: var(--bs-border-color-translucent); + } +} + +.app-card-hero { + grid-row: 1 / 3; + flex-direction: column; + align-items: flex-start; + padding: 1rem; + gap: 0.75rem; + + .app-card-icon { + width: 80px; + height: 80px; + } + + .app-card-title { + font-size: 1.1rem; + } + + .app-card-desc { + -webkit-line-clamp: 3; + } +} + +.app-card-icon { + width: 48px; + height: 48px; + border-radius: 0.5rem; + flex: 0 0 auto; +} + +.app-card-text { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 auto; +} + +.app-card-title { + font-weight: 600; + font-size: 0.95rem; + line-height: 1.2; +} + +.app-card-version { + font-size: 0.8rem; + color: var(--bs-secondary-color); + margin-top: 0.15rem; +} + +.app-card-desc { + font-size: 0.85rem; + color: var(--bs-secondary-color); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.app-card-tag { + position: absolute; + top: 0.5rem; + right: 0.5rem; +} diff --git a/src/app/apps/channel/channel.component.ts b/src/app/apps/channel/channel.component.ts index 4b358a7c..6d7f091a 100644 --- a/src/app/apps/channel/channel.component.ts +++ b/src/app/apps/channel/channel.component.ts @@ -1,8 +1,10 @@ -import {Component, Host, Input, OnInit, ChangeDetectionStrategy} from '@angular/core'; -import {Observable} from 'rxjs'; -import {AppsRepoService, RepositoryPage} from '../../core/services'; +import {ChangeDetectionStrategy, Component, Host, Injector, Input, OnDestroy, OnInit} from '@angular/core'; +import {Observable, Subscription} from 'rxjs'; +import {AppManagerService, AppsRepoService, RepositoryItem, RepositoryPage} from '../../core/services'; import {AppsComponent} from '../apps.component'; -import {RawPackageInfo} from "../../types"; +import {PackageInfo} from "../../types"; +import {DetailsComponent} from "../details/details.component"; +import {NgbOffcanvas} from "@ng-bootstrap/ng-bootstrap"; @Component({ selector: 'app-channel', @@ -11,24 +13,61 @@ import {RawPackageInfo} from "../../types"; changeDetection: ChangeDetectionStrategy.Eager, standalone: false }) -export class ChannelComponent implements OnInit { +export class ChannelComponent implements OnInit, OnDestroy { - page = 1; - repoPage$?: Observable; + page = 1; + repoPage$?: Observable; - @Input() - installed?: Record; + installedById: Record = {}; - constructor( - @Host() public parent: AppsComponent, - private appsRepo: AppsRepoService) { - } + private installedSubscription?: Subscription; - ngOnInit(): void { - this.loadPage(1); - } + constructor( + @Host() public parent: AppsComponent, + private appsRepo: AppsRepoService, + private appManager: AppManagerService, + private offcanvas: NgbOffcanvas) { + } - loadPage(page: number): void { - this.repoPage$ = this.appsRepo.allApps$(page); - } + ngOnInit(): void { + this.loadPage(1); + const device = this.parent.device; + if (device) { + this.installedSubscription = this.appManager.packages$(device).subscribe(pkgs => { + this.installedById = (pkgs ?? []).reduce((acc, p) => { + acc[p.id] = p; + return acc; + }, {} as Record); + }); + } + } + + ngOnDestroy(): void { + this.installedSubscription?.unsubscribe(); + } + + loadPage(page: number): void { + this.repoPage$ = this.appsRepo.allApps$(page); + } + + cardState(item: RepositoryItem): 'install' | 'installed' | 'update' { + const inst = this.installedById[item.id]; + if (!inst) return 'install'; + return item.manifest?.hasUpdate(inst.version) === true ? 'update' : 'installed'; + } + + openDetails(item: RepositoryItem) { + if (!this.parent.device) return; + this.offcanvas.open(DetailsComponent, { + position: 'end', + panelClass: 'app-detail-offcanvas', + injector: Injector.create({ + providers: [ + {provide: RepositoryItem, useValue: item}, + {provide: 'device', useValue: this.parent.device}, + {provide: 'parent', useValue: this.parent}, + ] + }) + }); + } } diff --git a/src/app/apps/details/details.component.html b/src/app/apps/details/details.component.html index 8d382083..0a1d8007 100644 --- a/src/app/apps/details/details.component.html +++ b/src/app/apps/details/details.component.html @@ -1,111 +1,125 @@ - + diff --git a/src/app/apps/details/details.component.scss b/src/app/apps/details/details.component.scss index 5e537a6b..f75fd044 100644 --- a/src/app/apps/details/details.component.scss +++ b/src/app/apps/details/details.component.scss @@ -1,12 +1,21 @@ +app-channel-app-details { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + .full-description { img { max-width: 100%; } } -.app-details-icon { - max-width: 25vw; - max-height: 25vw; - height: auto; - object-fit: contain; +.hero-icon { + border-radius: 0.75rem; + flex: 0 0 auto; +} + +.detail-footer { + flex: 0 0 auto; } diff --git a/src/app/apps/details/details.component.ts b/src/app/apps/details/details.component.ts index 24fe8691..a4bc9084 100644 --- a/src/app/apps/details/details.component.ts +++ b/src/app/apps/details/details.component.ts @@ -1,10 +1,10 @@ -import {Component, ElementRef, Inject, OnDestroy, OnInit, Renderer2, ViewChild, ViewEncapsulation, ChangeDetectionStrategy} from '@angular/core'; -import {AppManagerService, IncompatibleReason, PackageManifest, RepositoryItem} from "../../core/services"; +import {ChangeDetectionStrategy, Component, ElementRef, Inject, OnDestroy, OnInit, Renderer2, ViewChild, ViewEncapsulation} from '@angular/core'; +import {AppManagerService, DeviceManagerService, IncompatibleReason, PackageManifest, RepositoryItem} from "../../core/services"; import {noop, Observable, of} from "rxjs"; -import { AsyncPipe, NgOptimizedImage } from "@angular/common"; +import {AsyncPipe, NgOptimizedImage} from "@angular/common"; import {open as openPath} from "@tauri-apps/plugin-shell"; import { - NgbActiveModal, + NgbActiveOffcanvas, NgbDropdown, NgbDropdownItem, NgbDropdownMenu, @@ -19,15 +19,15 @@ import {ExternalLinkDirective} from "../../shared/directives"; @Component({ selector: 'app-channel-app-details', imports: [ - AsyncPipe, - NgOptimizedImage, - NgbDropdown, - NgbDropdownItem, - NgbDropdownMenu, - NgbDropdownToggle, - SharedModule, - ExternalLinkDirective -], + AsyncPipe, + NgOptimizedImage, + NgbDropdown, + NgbDropdownItem, + NgbDropdownMenu, + NgbDropdownToggle, + SharedModule, + ExternalLinkDirective + ], templateUrl: './details.component.html', styleUrl: './details.component.scss', changeDetection: ChangeDetectionStrategy.Eager, @@ -38,27 +38,42 @@ export class DetailsComponent implements OnInit, OnDestroy { fullDescriptionHtml$: Observable; installedInfo$?: Observable; - incompatible$: Observable; + incompatible$!: Observable; + devices$: Observable; + selectedDevice: Device; @ViewChild('fullDescription', {static: true}) fullDescription!: ElementRef; - parent?: AppsComponent; - - private unsubscribeClickListener!: () => void; constructor( - public modal: NgbActiveModal, + public offcanvas: NgbActiveOffcanvas, public item: RepositoryItem, @Inject('device') public device: Device, + @Inject('parent') private parent: AppsComponent, private appManager: AppManagerService, + private deviceManager: DeviceManagerService, private renderer2: Renderer2 ) { this.manifest = item.manifest!; - this.incompatible$ = fromPromise(this.appManager.checkIncompatibility(device, item)); + this.selectedDevice = device; + this.devices$ = this.deviceManager.devices$; this.fullDescriptionHtml$ = item.fullDescriptionUrl ? fromPromise(fetch(item.fullDescriptionUrl) .then(resp => resp.text())) : of(''); + this.refreshForDevice(); + } + + onDeviceChange(name: string): void { + let next: Device | undefined; + this.devices$.subscribe(devices => next = devices?.find(d => d.name === name)).unsubscribe(); + if (!next) return; + this.selectedDevice = next; + this.refreshForDevice(); + } + + private refreshForDevice(): void { + this.incompatible$ = fromPromise(this.appManager.checkIncompatibility(this.selectedDevice, this.item)); this.reloadInstalledInfo(); } @@ -75,15 +90,20 @@ export class DetailsComponent implements OnInit, OnDestroy { this.unsubscribeClickListener(); } + launchApp(id: string) { + this.parent.launchApp(id); + } + installPackage(item: RepositoryItem, channel: 'stable' | 'beta' = 'stable') { - this.parent?.installPackage(item, channel).then((installed) => installed && this.reloadInstalledInfo()); + this.parent.installPackage(item, channel, this.selectedDevice) + .then((installed) => installed && this.reloadInstalledInfo()); } removePackage(item: PackageInfo) { - this.parent?.removePackage(item).then((removed) => removed && this.reloadInstalledInfo()); + this.parent.removePackage(item).then((removed) => removed && this.reloadInstalledInfo()); } private reloadInstalledInfo(): void { - this.installedInfo$ = fromPromise(this.appManager.info(this.device, this.item.id)); + this.installedInfo$ = fromPromise(this.appManager.info(this.selectedDevice, this.item.id)); } } diff --git a/src/app/apps/installed/details/details.component.html b/src/app/apps/installed/details/details.component.html new file mode 100644 index 00000000..6dbae694 --- /dev/null +++ b/src/app/apps/installed/details/details.component.html @@ -0,0 +1,68 @@ +
+
+ +
+

{{ pkg.title }}

+
+ v{{ pkg.version }} + @if (hasUpdate) { + › v{{ repoPackage!.manifest!.version }} + } +
+ @if (hasUpdate) { + Update available + } +
+
+ + @if (hasUpdate) { + + } + +
+
+
+ @if (pkg.appDescription) { +
About
+

{{ pkg.appDescription }}

+ } +
Details
+
+
App ID
+
{{ pkg.id }}
+ @if (pkg.vendor) { +
Vendor
+
{{ pkg.vendor }}
+ } + @if (pkg.type) { +
Type
+
{{ pkg.type }}
+ } +
Path
+
{{ pkg.folderPath }}
+
Disk usage
+
+ @let du = diskUsage$ | async; + @if (!du || (!du.result && !du.error)) { + Calculating… + } @else if (du.error) { + Unavailable + } @else if (du.result) { + {{ du.result.total * 1024 | filesize:sizeOptions }} + } +
+ @if (sourceUrl) { +
Website
+
+ {{ sourceUrl }} +
+ } +
+
+
diff --git a/src/app/apps/installed/details/details.component.scss b/src/app/apps/installed/details/details.component.scss new file mode 100644 index 00000000..d063d4a3 --- /dev/null +++ b/src/app/apps/installed/details/details.component.scss @@ -0,0 +1,10 @@ +:host { + display: block; + height: 100%; +} + +.detail-icon { + width: 64px; + height: 64px; + border-radius: 0.75rem; +} diff --git a/src/app/apps/installed/details/details.component.spec.ts b/src/app/apps/installed/details/details.component.spec.ts new file mode 100644 index 00000000..b76b5529 --- /dev/null +++ b/src/app/apps/installed/details/details.component.spec.ts @@ -0,0 +1,98 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {Mock, vi} from 'vitest'; + +import {DetailsComponent} from './details.component'; +import {AppManagerService, PackageManifest, RepositoryItem} from '../../../core/services'; +import {Device, PackageInfo} from '../../../types'; +import {AppsComponent} from '../../apps.component'; + +describe('InstalledDetailsComponent', () => { + let component: DetailsComponent; + let fixture: ComponentFixture; + let parentSpy: {launchApp: Mock; removePackage: Mock; installPackage: Mock}; + + const device: Device = { + name: 'test', host: '192.168.1.1', port: 22, username: 'prisoner', + profile: 'ose', privateKey: {openSsh: 'test'}, + }; + const pkg: PackageInfo = { + id: 'com.example.app', title: 'Example', version: '1.0.0', + folderPath: '/media/developer/apps/usr/palm/applications/com.example.app', + iconUri: '', appDescription: '', + }; + + function setup(repoPackage: RepositoryItem | null = null) { + parentSpy = { + launchApp: vi.fn(), + removePackage: vi.fn().mockResolvedValue(true), + installPackage: vi.fn().mockResolvedValue(true), + }; + const appManagerStub = { + appDiskUsage: () => Promise.reject(new Error('not under test')), + } as Partial; + + TestBed.configureTestingModule({ + imports: [DetailsComponent], + providers: [ + {provide: AppManagerService, useValue: appManagerStub}, + ], + }); + + fixture = TestBed.createComponent(DetailsComponent); + component = fixture.componentInstance; + component.pkg = pkg; + component.device = device; + // Only the three methods the details view calls are stubbed. + component.parent = parentSpy as unknown as AppsComponent; + component.repoPackage = repoPackage; + component.ngOnChanges({ + pkg: {currentValue: pkg, previousValue: undefined, firstChange: true, isFirstChange: () => true}, + device: {currentValue: device, previousValue: undefined, firstChange: true, isFirstChange: () => true}, + }); + fixture.detectChanges(); + } + + it('should create', () => { + setup(); + expect(component).toBeTruthy(); + }); + + it('Launch calls parent.launchApp', () => { + setup(); + component.launch(); + expect(parentSpy.launchApp).toHaveBeenCalledWith(pkg.id); + }); + + it('Uninstall calls parent.removePackage with the package', async () => { + setup(); + const removed = await component.uninstall(); + expect(parentSpy.removePackage).toHaveBeenCalledWith(pkg); + expect(removed).toBe(true); + }); + + it('Update calls parent.installPackage when a repo package is available', async () => { + const newer = new RepositoryItem({manifest: new PackageManifest({version: '2.0.0'})}, ''); + setup(newer); + const installed = await component.update(); + expect(parentSpy.installPackage).toHaveBeenCalledWith(newer); + expect(installed).toBe(true); + }); + + it('Update is a no-op without a repo package', async () => { + setup(null); + const installed = await component.update(); + expect(installed).toBe(false); + expect(parentSpy.installPackage).not.toHaveBeenCalled(); + }); + + it('hasUpdate is false when no repo package is provided', () => { + setup(null); + expect(component.hasUpdate).toBe(false); + }); + + it('hasUpdate reflects the repo manifest comparison', () => { + const newer = new RepositoryItem({manifest: new PackageManifest({version: '2.0.0'})}, ''); + setup(newer); + expect(component.hasUpdate).toBe(true); + }); +}); diff --git a/src/app/apps/installed/details/details.component.ts b/src/app/apps/installed/details/details.component.ts new file mode 100644 index 00000000..0ae9fad5 --- /dev/null +++ b/src/app/apps/installed/details/details.component.ts @@ -0,0 +1,65 @@ +import {Component, Input, OnChanges, SimpleChanges} from '@angular/core'; +import {AsyncResult, Device, PackageInfo} from "../../../types"; +import {AppManagerService, PackageDiskUsage, RepositoryItem} from "../../../core/services"; +import {fromPromise} from "rxjs/internal/observable/innerFrom"; +import {Observable, of} from "rxjs"; +import {AsyncPipe} from "@angular/common"; +import {SharedModule} from "../../../shared/shared.module"; +import {FilesizePipe} from "../../../shared/pipes/filesize.pipe"; +import {FileSizeOptions} from "filesize"; +import {AppsComponent} from "../../apps.component"; + +@Component({ + selector: 'app-installed-details', + standalone: true, + imports: [ + AsyncPipe, + SharedModule, + FilesizePipe + ], + templateUrl: './details.component.html', + styleUrl: './details.component.scss' +}) +export class DetailsComponent implements OnChanges { + + @Input() pkg!: PackageInfo; + @Input() device!: Device; + @Input() parent!: AppsComponent; + @Input() repoPackage: RepositoryItem | null = null; + + diskUsage$: Observable> = of({}); + sizeOptions: FileSizeOptions = {base: 2, standard: 'jedec'}; + + constructor(private appManager: AppManagerService) { + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['pkg'] || changes['device']) { + this.diskUsage$ = this.pkg && this.device + ? fromPromise(this.appManager.appDiskUsage(this.device, this.pkg.folderPath) + .then((result) => ({result})).catch((error) => ({error}))) + : of({}); + } + } + + get hasUpdate(): boolean { + return this.repoPackage?.manifest?.hasUpdate(this.pkg.version) === true; + } + + launch(): void { + this.parent.launchApp(this.pkg.id); + } + + uninstall(): Promise { + return this.parent.removePackage(this.pkg); + } + + update(): Promise { + if (!this.repoPackage) return Promise.resolve(false); + return this.parent.installPackage(this.repoPackage); + } + + get sourceUrl(): string | undefined { + return this.repoPackage?.manifest?.sourceUrl; + } +} diff --git a/src/app/apps/installed/installed.component.html b/src/app/apps/installed/installed.component.html index 62aa3c8e..fe412b89 100644 --- a/src/app/apps/installed/installed.component.html +++ b/src/app/apps/installed/installed.component.html @@ -1,35 +1,65 @@ -@let installed = installed$ | async; -@if (installedError) { - - -} @else if (installed) { -
    - @for (pkg of installed; track pkg.id) { -
  • -
    - - -
    -
    {{ pkg.title }}
    -
    - v{{ pkg.version }} - @let rpkg = repoPackages && repoPackages[pkg.id]; - @if (rpkg && rpkg.manifest?.hasUpdate(pkg.version)) { -  › v{{ rpkg.manifest?.version }} - } -
    -
    -
    - @if (repoPackages?.[pkg.id]?.manifest?.hasUpdate(pkg.version)) { - - } -
  • +
    + +
    + @if (selectedPkg && device) { + + } @else { +
    + +
    Select an installed app to see details
    +
    } -
-} @else { - -} + + +
+ +
+ +
+
diff --git a/src/app/apps/installed/installed.component.scss b/src/app/apps/installed/installed.component.scss index e69de29b..71ebfa3f 100644 --- a/src/app/apps/installed/installed.component.scss +++ b/src/app/apps/installed/installed.component.scss @@ -0,0 +1,40 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +.installed-body { + min-height: 0; +} + +.installed-list-pane { + width: 320px; + flex: 0 0 auto; +} + +.installed-row { + cursor: pointer; + border-left: 3px solid transparent; + + &.active { + background: var(--bs-primary-bg-subtle); + border-left-color: var(--bs-primary); + color: inherit; + } +} + +.installed-row-icon { + width: 36px; + height: 36px; + border-radius: 0.4rem; +} + +.installed-row-title { + font-weight: 500; +} + +.installed-empty { + padding: 2rem; +} diff --git a/src/app/apps/installed/installed.component.ts b/src/app/apps/installed/installed.component.ts index 6bb117c2..6d93a903 100644 --- a/src/app/apps/installed/installed.component.ts +++ b/src/app/apps/installed/installed.component.ts @@ -1,8 +1,10 @@ -import {Component, Host, Input, OnDestroy, ChangeDetectionStrategy} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Host, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild} from '@angular/core'; import {AppsComponent} from '../apps.component'; import {Device, PackageInfo} from "../../types"; import {Observable, Subscription} from "rxjs"; -import {AppsRepoService, RepositoryItem} from "../../core/services"; +import {AppManagerService, AppsRepoService, RepositoryItem} from "../../core/services"; +import {fromPromise} from "rxjs/internal/observable/innerFrom"; +import {StatStorageInfoComponent} from "../../shared/components/stat-storage-info/stat-storage-info.component"; @Component({ selector: 'app-installed', @@ -11,49 +13,79 @@ import {AppsRepoService, RepositoryItem} from "../../core/services"; changeDetection: ChangeDetectionStrategy.Eager, standalone: false }) -export class InstalledComponent implements OnDestroy { +export class InstalledComponent implements OnChanges, OnInit, OnDestroy { - @Input() - device: Device | null = null; + @Input() device: Device | null = null; + + installed$: Observable | undefined; installedError?: Error; repoPackages?: Record; - private subscription?: Subscription; - private installedField?: Observable; + selectedPkg: PackageInfo | null = null; + filterText = ''; - constructor(@Host() public parent: AppsComponent, private appsRepo: AppsRepoService) { - } + @ViewChild('storageInfo') storageInfo?: StatStorageInfoComponent; - @Input() - set installed$(value: Observable | undefined) { - this.subscription?.unsubscribe(); - this.subscription = value?.subscribe({ - next: (pkgs) => { - this.installedError = undefined; + private storageSubscription?: Subscription; - const strings: string[] = pkgs?.map((pkg) => pkg.id) ?? []; - this.appsRepo.showApps(...strings).then(apps => this.repoPackages = apps); - }, - error: (error) => { - console.log('installed apps', error); - return this.installedError = error; - } - }); - this.installedField = value; + constructor(@Host() public parent: AppsComponent, + private appManager: AppManagerService, private appsRepo: AppsRepoService) { } - get installed$(): Observable | undefined { - return this.installedField; + ngOnInit(): void { + this.storageSubscription = this.parent.storageChanged$.subscribe(() => { + this.storageInfo?.refresh(); + this.loadPackages(); + }); } ngOnDestroy(): void { - this.subscription?.unsubscribe(); + this.storageSubscription?.unsubscribe(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['device']) { + this.selectedPkg = null; + this.loadPackages(); + } } loadPackages(): void { + const device = this.device; this.installedError = undefined; - this.parent.loadPackages(); + this.repoPackages = undefined; + if (!device) { + this.installed$ = undefined; + return; + } + this.installed$ = fromPromise(this.appManager.load(device).then(packages => { + this.appsRepo.showApps(...packages.map(p => p.id)) + .then(repo => this.repoPackages = repo) + .catch(() => undefined); + this.reconcileSelection(packages); + return packages; + })); + } + + selectPackage(pkg: PackageInfo): void { + this.selectedPkg = pkg; + } + + matchesFilter(pkg: PackageInfo): boolean { + if (!this.filterText) return true; + const q = this.filterText.toLowerCase(); + return pkg.title.toLowerCase().includes(q) || pkg.id.toLowerCase().includes(q); + } + + hasUpdate(pkg: PackageInfo): boolean { + return this.repoPackages?.[pkg.id]?.manifest?.hasUpdate(pkg.version) === true; + } + + private reconcileSelection(packages: PackageInfo[]): void { + if (!this.selectedPkg) return; + const match = packages.find(p => p.id === this.selectedPkg!.id); + this.selectedPkg = match ?? null; } } diff --git a/src/app/core/services/app-manager.service.ts b/src/app/core/services/app-manager.service.ts index fb321fe6..788748a0 100644 --- a/src/app/core/services/app-manager.service.ts +++ b/src/app/core/services/app-manager.service.ts @@ -1,6 +1,6 @@ import {Injectable} from '@angular/core'; import {BehaviorSubject, catchError, firstValueFrom, lastValueFrom, mergeMap, noop, Observable, Subject} from 'rxjs'; -import {Device, PackageInfo, RawPackageInfo} from '../../types'; +import {Device, DeviceLike, PackageInfo, RawPackageInfo} from '../../types'; import { LunaResponse, LunaResponseError, @@ -279,6 +279,37 @@ export class AppManagerService { ); } + async appDiskUsage(device: DeviceLike, appDir: string): Promise { + const appIndex = appDir.indexOf('/usr/palm/applications/'); + if (appIndex < 0) { + throw new Error('Not accepted appDir format: ' + appDir); + } + const installBase = appDir.substring(0, appIndex); + const appId = appDir.substring(appIndex + 23); + const pkgInfo = JSON.parse(await this.file.read(device, `${installBase}/usr/palm/packages/${appId}/packageinfo.json`, undefined, 'utf-8')) as PkgInfo; + const dirs = [appDir]; + pkgInfo.services?.forEach(service => { + const serviceDir = `${installBase}/usr/palm/services/${service}`; + dirs.push(serviceDir); + }); + return await this.cmd.exec(device, `xargs du -d 0 -c`, 'utf-8', dirs.join('\n')) + .then(stdout => (Object.fromEntries(stdout.split('\n').map(line => line.match(/(\d+)\t(.+)/)) + .map((match): [keyof PackageDiskUsage, number] | null => { + if (!match) { + return null; + } + const size = parseInt(match[1] ?? '0'); + const path = match[2] ?? ''; + if (path === 'total') { + return ['total', size]; + } else if (path.includes('/usr/palm/applications')) { + return ['application', size]; + } else if (path.includes('/usr/palm/services')) { + return [path.substring(path.lastIndexOf('/')), size]; + } + return null; + }).filter(v => v) as Iterable<[keyof PackageDiskUsage, number]>) as unknown as PackageDiskUsage)); + } } function mapAppinstalldResponse(v: LunaResponse, expectResult: string | RegExp): boolean { @@ -301,6 +332,10 @@ function mapAppinstalldResponse(v: LunaResponse, expectResult: string | RegExp): return false; } +interface PkgInfo { + services?: string[]; +} + export interface InstallProgressHandler { (progress?: number, statusText?: string): void; } @@ -314,3 +349,10 @@ export class InstallError extends Error { return new InstallError('Can\'t install because of insufficient space', details); } } + +export interface PackageDiskUsage { + application: number; + total: number; + + [service: string]: number; +} diff --git a/src/app/files/files.module.ts b/src/app/files/files.module.ts index 53296b53..9813047f 100644 --- a/src/app/files/files.module.ts +++ b/src/app/files/files.module.ts @@ -15,6 +15,7 @@ import {FilesTableComponent} from './files-table/files-table.component'; import {SharedModule} from "../shared/shared.module"; import {CreateDirectoryMessageComponent} from './create-directory-message/create-directory-message.component'; import {ReactiveFormsModule} from "@angular/forms"; +import {FilesizePipe} from "../shared/pipes/filesize.pipe"; @NgModule({ @@ -24,17 +25,18 @@ import {ReactiveFormsModule} from "@angular/forms"; FilesTableComponent, CreateDirectoryMessageComponent, ], - imports: [ - CommonModule, - FilesRoutingModule, - NgbTooltipModule, - SharedModule, - NgbDropdown, - NgbDropdownItem, - NgbDropdownMenu, - NgbDropdownToggle, - ReactiveFormsModule, - ] + imports: [ + CommonModule, + FilesRoutingModule, + NgbTooltipModule, + SharedModule, + NgbDropdown, + NgbDropdownItem, + NgbDropdownMenu, + NgbDropdownToggle, + ReactiveFormsModule, + FilesizePipe, + ] }) export class FilesModule { } diff --git a/src/app/shared/pipes/filesize.pipe.ts b/src/app/shared/pipes/filesize.pipe.ts index a6c9d08a..34bc576b 100644 --- a/src/app/shared/pipes/filesize.pipe.ts +++ b/src/app/shared/pipes/filesize.pipe.ts @@ -1,13 +1,13 @@ import {Pipe, PipeTransform} from "@angular/core"; -import {filesize, FileSizeOptionsBase} from 'filesize'; +import {filesize, FileSizeOptions} from 'filesize'; @Pipe({ name: 'filesize', - standalone: false + standalone: true }) export class FilesizePipe implements PipeTransform { - transform(bytes: number, options: Partial): string { + transform(bytes: number, options: Partial): string { return filesize(bytes, {output: "string", ...options}); } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index a95a83d4..45be2a06 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -17,7 +17,6 @@ import {FilesizePipe} from "./pipes/filesize.pipe"; declarations: [ PageNotFoundComponent, TrustUriPipe, - FilesizePipe, MessageDialogComponent, ProgressDialogComponent, MessageTraceComponent, @@ -26,11 +25,10 @@ import {FilesizePipe} from "./pipes/filesize.pipe"; StatStorageInfoComponent, ], imports: [CommonModule, FormsModule, NgbModule, - ExternalLinkDirective], + ExternalLinkDirective, FilesizePipe], exports: [ PageNotFoundComponent, TrustUriPipe, - FilesizePipe, MessageDialogComponent, ProgressDialogComponent, MessageTraceComponent, diff --git a/src/app/types/index.ts b/src/app/types/index.ts index 76b77faf..c908fe04 100644 --- a/src/app/types/index.ts +++ b/src/app/types/index.ts @@ -1,3 +1,8 @@ export * from './device-manager'; export * from './file-session'; export * from './device'; + +export interface AsyncResult { + result?: T; + error?: E; +} diff --git a/src/styles.scss b/src/styles.scss index ffabc992..21b05453 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -33,3 +33,20 @@ body { .bg-panel { @extend .bg-dark-subtle; } + +app-apps .tab-content > .tab-pane.active { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +.offcanvas.app-detail-offcanvas { + --bs-offcanvas-width: 480px; +} + +@media (max-width: 575.98px) { + .offcanvas.app-detail-offcanvas { + --bs-offcanvas-width: 100%; + } +} diff --git a/src/styles/shared.scss b/src/styles/shared.scss index ac8bad63..b67c3281 100644 --- a/src/styles/shared.scss +++ b/src/styles/shared.scss @@ -14,6 +14,7 @@ .storage-info-bar { width: 25%; + height: 15px; max-width: 110px; }