0.2.5 initial commit

Makefile incomplete
This commit is contained in:
Aiden McClelland
2020-11-23 13:44:28 -07:00
commit 95d3845906
503 changed files with 53448 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
//////////////// Install/Uninstall ////////////////////////////////////////////////
type AppDependentBreakage = {
// id of the dependent app which will or did break (Stopped) given the action.
id: string
title: string
iconUrl: string
}
POST /apps/:appId/install(?dryrun)
body: {
version: string, //semver
}
response : ApiAppInstalledFull & { breakages: AppDependentBreakage[] }
POST /apps/:appId/uninstall(?dryrun)
response : { breakages: AppDependentBreakage[] }
/////////////////////////////// Store/Show /////////////////////////////////////////////////
type ApiAppAvailableFull = ... {
// app base data
id: string
title: string
status: AppStatus | null
versionInstalled: string | null
iconURL: string
// preview data
versionLatest: string
descriptionShort: string
// version specific data
releaseNotes: string
serviceRequirements: AppDependencyRequirement[]
// other data
descriptionLong: string,
version: string[],
}
type AppDependencyRequirement = ... {
//app base data (minus status + version installed)
id: string
title: string
iconURL: string
// dependency data
optional: string | null
default: boolean
versionSpec: string
description: string | null
violation: AppDependencyRequirementViolation | null
}
type AppDependencyRequirementViolation =
{ name: 'missing'; suggestedVersion: string; } |
{ name: 'incompatible-version'; suggestedVersion: string; } |
{ name: 'incompatible-config'; ruleViolations: string; auto-configurable: boolean } | // (auto-configurable for if/when we do that)
{ name: 'incompatible-status'; status: AppStatus; }
// Get App Available Full
GET /apps/:appId/store
response: ApiAppAvailableFull
// Get Version Specific Data for an App Available
GET /apps/:appId/store/:version
response: {
// version specific data
releaseNotes: string
serviceRequirements: AppDependencyRequirement[]
}
///////////////////////////// Installed/Show ///////////////////////////////////////////
type ApiAppInstalledFull {
// app base data
id: string
title: string
status: AppStatus | null
versionInstalled: string | null
iconURL: string
// preview data
// other data
instructions: string | null
lastBackup: string | null
configuredRequirements: AppDependencyRequirement[] | null // null if not yet configured
}
// Get App Installed Full
GET /apps/:appId/installed
reseponse: AppInstalledFull

View File

@@ -0,0 +1,32 @@
import { ConfigSpec } from 'src/app/app-config/config-types'
import { AppAvailableFull, AppInstalledFull } from 'src/app/models/app-types'
import { Rules } from '../../models/app-model'
import { SSHFingerprint, ServerStatus, ServerSpecs } from '../../models/server-model'
/** SERVER **/
export interface ApiServer {
name: string
status: ServerStatus
versionInstalled: string
alternativeRegistryUrl: string | null
specs: ServerSpecs
wifi: { ssids: string[]; current: string; }
ssh: SSHFingerprint[]
serverId: string
}
/** APPS **/
export type ApiAppAvailableFull = Omit<AppAvailableFull, 'versionViewing'>
export type ApiAppInstalledFull = Omit<AppInstalledFull, 'hasFetchedFull'>
export interface ApiAppConfig {
spec: ConfigSpec
config: object | null
rules: Rules[]
}
/** MISC **/
export type Unit = { never?: never; } // hack for the unit typ

View File

@@ -0,0 +1,14 @@
import { HttpService } from '../http.service'
import { AppModel } from '../../models/app-model'
import { MockApiService } from './mock-api.service'
import { LiveApiService } from './live-api.service'
import { ServerModel } from 'src/app/models/server-model'
import { ConfigService } from '../config.service'
export function ApiServiceFactory (config: ConfigService, http: HttpService, appModel: AppModel, serverModel: ServerModel) {
if (config.api.useMocks) {
return new MockApiService(appModel, serverModel)
} else {
return new LiveApiService(http, appModel, serverModel)
}
}

View File

@@ -0,0 +1,97 @@
import { Rules } from '../../models/app-model'
import { AppAvailablePreview, AppAvailableFull, AppInstalledPreview, AppInstalledFull, DependentBreakage, AppAvailableVersionSpecificInfo } from '../../models/app-types'
import { S9Notification, SSHFingerprint, ServerMetrics, DiskInfo } from '../../models/server-model'
import { Subject, Observable } from 'rxjs'
import { Unit, ApiServer, ApiAppInstalledFull, ApiAppConfig, ApiAppAvailableFull } from './api-types'
import { AppMetrics, AppMetricsVersioned } from 'src/app/util/metrics.util'
import { ConfigSpec } from 'src/app/app-config/config-types'
export abstract class ApiService {
private $unauthorizedApiResponse$: Subject<{ }> = new Subject()
watch401$ (): Observable<{ }> {
return this.$unauthorizedApiResponse$.asObservable()
}
authenticatedRequestsEnabled: boolean = false
protected received401 () {
this.authenticatedRequestsEnabled = false
this.$unauthorizedApiResponse$.next()
}
abstract getCheckAuth (): Promise<Unit> // Throws an error on failed auth.
abstract postLogin (password: string): Promise<Unit> // Throws an error on failed auth.
abstract postLogout (): Promise<Unit> // Throws an error on failed auth.
abstract getServer (timeout?: number): Promise<ApiServer>
abstract getVersionLatest (): Promise<ReqRes.GetVersionLatestRes>
abstract getServerMetrics (): Promise<ReqRes.GetServerMetricsRes>
abstract getNotifications (page: number, perPage: number): Promise<S9Notification[]>
abstract deleteNotification (id: string): Promise<Unit>
abstract updateAgent (thing: any): Promise<Unit>
abstract getAvailableApps (): Promise<AppAvailablePreview[]>
abstract getAvailableApp (appId: string): Promise<AppAvailableFull>
abstract getAvailableAppVersionSpecificInfo (appId: string, versionSpec: string): Promise<AppAvailableVersionSpecificInfo>
abstract getInstalledApp (appId: string): Promise<AppInstalledFull>
abstract getAppMetrics (appId: string): Promise<AppMetrics>
abstract getInstalledApps (): Promise<AppInstalledPreview[]>
abstract getExternalDisks (): Promise<DiskInfo[]>
abstract getAppConfig (appId: string): Promise<{ spec: ConfigSpec, config: object, rules: Rules[]}>
abstract getAppLogs (appId: string, params?: ReqRes.GetAppLogsReq): Promise<string[]>
abstract installApp (appId: string, version: string, dryRun?: boolean): Promise<AppInstalledFull & { breakages: DependentBreakage[] } >
abstract uninstallApp (appId: string, dryRun?: boolean): Promise<{ breakages: DependentBreakage[] }>
abstract startApp (appId: string): Promise<Unit>
abstract stopApp (appId: string, dryRun?: boolean): Promise<{ breakages: DependentBreakage[] }>
abstract restartApp (appId: string): Promise<Unit>
abstract createAppBackup (appId: string, logicalname: string, password?: string): Promise<Unit>
abstract restoreAppBackup (appId: string, logicalname: string, password?: string): Promise<Unit>
abstract stopAppBackup (appId: string): Promise<Unit>
abstract patchAppConfig (app: AppInstalledPreview, config: object, dryRun?: boolean): Promise<{ breakages: DependentBreakage[] }>
abstract postConfigureDependency(dependencyId: string, dependentId: string, dryRun?: boolean): Promise<{config: object, breakages: DependentBreakage[] }>
abstract patchServerConfig (attr: string, value: any): Promise<Unit>
abstract wipeAppData (app: AppInstalledPreview): Promise<Unit>
abstract addSSHKey (sshKey: string): Promise<Unit>
abstract deleteSSHKey (sshKey: SSHFingerprint): Promise<Unit>
abstract addWifi (ssid: string, password: string, country: string, connect: boolean): Promise<Unit>
abstract connectWifi (ssid: string): Promise<Unit>
abstract deleteWifi (ssid: string): Promise<Unit>
abstract restartServer (): Promise<Unit>
abstract shutdownServer (): Promise<Unit>
}
export module ReqRes {
export type GetVersionRes = { version: string }
export type PostLoginReq = { password: string }
export type PostLoginRes = Unit
export type GetCheckAuthRes = { }
export type GetServerRes = ApiServer
export type GetVersionLatestRes = { versionLatest: string, canUpdate: boolean }
export type GetServerMetricsRes = ServerMetrics
export type GetAppAvailableRes = ApiAppAvailableFull
export type GetAppAvailableVersionInfoRes = AppAvailableVersionSpecificInfo
export type GetAppsAvailableRes = AppAvailablePreview[]
export type GetExternalDisksRes = DiskInfo[]
export type GetAppInstalledRes = ApiAppInstalledFull
export type GetAppConfigRes = ApiAppConfig
export type GetAppLogsReq = { after?: string, before?: string, page?: string, perPage?: string }
export type GetAppLogsRes = string[]
export type GetAppMetricsRes = AppMetricsVersioned<number>
export type GetAppsInstalledRes = AppInstalledPreview[]
export type PostInstallAppReq = { version: string }
export type PostInstallAppRes = ApiAppInstalledFull & { breakages: DependentBreakage[] }
export type PostUpdateAgentReq = { version: string }
export type PostAppBackupCreateReq = { logicalname: string, password: string }
export type PostAppBackupCreateRes = Unit
export type PostAppBackupRestoreReq = { logicalname: string, password: string }
export type PostAppBackupRestoreRes = Unit
export type PostAppBackupStopRes = Unit
export type PatchAppConfigReq = { config: object }
export type PatchServerConfigReq = { value: string }
export type GetNotificationsReq = { page: string, perPage: string }
export type GetNotificationsRes = S9Notification[]
export type PostAddWifiReq = { ssid: string, password: string, country: string, skipConnect: boolean }
export type PostConnectWifiReq = { country: string }
export type PostAddSSHKeyReq = { sshKey: string }
export type PostAddSSHKeyRes = SSHFingerprint
}

View File

@@ -0,0 +1,257 @@
import { Injectable } from '@angular/core'
import { HttpService, Method, HttpOptions } from '../http.service'
import { AppModel, AppStatus } from '../../models/app-model'
import { AppAvailablePreview, AppAvailableFull, AppInstalledFull, AppInstalledPreview, DependentBreakage, AppAvailableVersionSpecificInfo } from '../../models/app-types'
import { S9Notification, SSHFingerprint, ServerModel, DiskInfo } from '../../models/server-model'
import { ApiService, ReqRes } from './api.service'
import { ApiServer, Unit } from './api-types'
import { HttpErrorResponse } from '@angular/common/http'
import { isUnauthorized } from 'src/app/util/web.util'
import { Replace } from 'src/app/util/types.util'
import { AppMetrics, parseMetricsPermissive } from 'src/app/util/metrics.util'
@Injectable()
export class LiveApiService extends ApiService {
constructor (
private readonly http: HttpService,
// TODO remove app + server model from here. updates to state should be done in a separate class wrapping ApiService + App/ServerModel
private readonly appModel: AppModel,
private readonly serverModel: ServerModel,
) { super() }
// Used to check whether password or key is valid. If so, it will be used implicitly by all other calls.
async getCheckAuth (): Promise<Unit> {
return this.http.serverRequest<Unit>({ method: Method.GET, url: '/authenticate' }, { version: '' })
}
async postLogin (password: string): Promise<Unit> {
return this.http.serverRequest<Unit>({ method: Method.POST, url: '/auth/login', data: { password } }, { version: '' })
}
async postLogout (): Promise<Unit> {
return this.http.serverRequest<Unit>({ method: Method.POST, url: '/auth/logout' }, { version: '' }).then(() => { this.authenticatedRequestsEnabled = false; return { } })
}
async getServer (timeout?: number): Promise<ApiServer> {
return this.authRequest<ReqRes.GetServerRes>({ method: Method.GET, url: '/', readTimeout: timeout })
}
async getVersionLatest (): Promise<ReqRes.GetVersionLatestRes> {
return this.authRequest<ReqRes.GetVersionLatestRes>({ method: Method.GET, url: '/versionLatest' }, { version: '' })
}
async getServerMetrics (): Promise<ReqRes.GetServerMetricsRes> {
return this.authRequest<ReqRes.GetServerMetricsRes>({ method: Method.GET, url: `/metrics` })
}
async getNotifications (page: number, perPage: number): Promise<S9Notification[]> {
const params: ReqRes.GetNotificationsReq = {
page: String(page),
perPage: String(perPage),
}
return this.authRequest<ReqRes.GetNotificationsRes>({ method: Method.GET, url: `/notifications`, params })
}
async deleteNotification (id: string): Promise<Unit> {
return this.authRequest({ method: Method.DELETE, url: `/notifications/${id}` })
}
async getExternalDisks (): Promise<DiskInfo[]> {
return this.authRequest<ReqRes.GetExternalDisksRes>({ method: Method.GET, url: `/disks` })
}
async updateAgent (version: string): Promise<Unit> {
const data: ReqRes.PostUpdateAgentReq = {
version: `=${version}`,
}
return this.authRequest({ method: Method.POST, url: '/update', data })
}
async getAvailableAppVersionSpecificInfo (appId: string, versionSpec: string): Promise<AppAvailableVersionSpecificInfo> {
return this
.authRequest<Replace<ReqRes.GetAppAvailableVersionInfoRes, 'versionViewing', 'version'>>( { method: Method.GET, url: `/apps/${appId}/store/${versionSpec}` })
.then( res => ({ ...res, versionViewing: res.version }))
.then( res => {
delete res['version']
return res
})
}
async getAvailableApps (): Promise<AppAvailablePreview[]> {
return this.authRequest<ReqRes.GetAppsAvailableRes>({ method: Method.GET, url: '/apps/store' })
}
async getAvailableApp (appId: string): Promise<AppAvailableFull> {
return this.authRequest<ReqRes.GetAppAvailableRes>({ method: Method.GET, url: `/apps/${appId}/store` })
.then(res => {
return {
...res,
versionViewing: res.versionLatest,
}
})
}
async getInstalledApp (appId: string): Promise<AppInstalledFull> {
return this.authRequest<ReqRes.GetAppInstalledRes>({ method: Method.GET, url: `/apps/${appId}/installed` })
.then(app => ({ ...app, hasFetchedFull: true }))
}
async getInstalledApps (): Promise<AppInstalledPreview[]> {
return this.authRequest<ReqRes.GetAppsInstalledRes>({ method: Method.GET, url: `/apps/installed` })
}
async getAppConfig ( appId: string): Promise<ReqRes.GetAppConfigRes> {
return this.authRequest<ReqRes.GetAppConfigRes>({ method: Method.GET, url: `/apps/${appId}/config` })
}
async getAppLogs (appId: string, params: ReqRes.GetAppLogsReq = { }): Promise<string[]> {
return this.authRequest<ReqRes.GetAppLogsRes>( { method: Method.GET, url: `/apps/${appId}/logs`, params: params as any })
}
async getAppMetrics (appId: string): Promise<AppMetrics> {
return this.authRequest<ReqRes.GetAppMetricsRes | string>( { method: Method.GET, url: `/apps/${appId}/metrics` })
.then(parseMetricsPermissive)
}
async installApp (appId: string, version: string, dryRun: boolean = false): Promise<AppInstalledFull & { breakages: DependentBreakage[] }> {
const data: ReqRes.PostInstallAppReq = {
version,
}
return this.authRequest<ReqRes.PostInstallAppRes>({ method: Method.POST, url: `/apps/${appId}/install${dryRunParam(dryRun, true)}`, data })
.then(res => ({ ...res, hasFetchedFull: false }))
}
async uninstallApp (appId: string, dryRun: boolean = false): Promise<{ breakages: DependentBreakage[] }> {
return this.authRequest({ method: Method.POST, url: `/apps/${appId}/uninstall${dryRunParam(dryRun, true)}`, readTimeout: 30000 })
}
async startApp (appId: string): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: `/apps/${appId}/start`, readTimeout: 30000 })
.then(() => this.appModel.update({ id: appId, status: AppStatus.RUNNING }))
.then(() => ({ }))
}
async stopApp (appId: string, dryRun: boolean = false): Promise<{ breakages: DependentBreakage[] }> {
const res = await this.authRequest<{ breakages: DependentBreakage[] }>({ method: Method.POST, url: `/apps/${appId}/stop${dryRunParam(dryRun, true)}`, readTimeout: 30000 })
if (!dryRun) this.appModel.update({ id: appId, status: AppStatus.STOPPING })
return res
}
async restartApp (appId: string): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: `/apps/${appId}/restart`, readTimeout: 30000 })
.then(() => ({ } as any))
}
async createAppBackup (appId: string, logicalname: string, password?: string): Promise<Unit> {
const data: ReqRes.PostAppBackupCreateReq = {
password: password || undefined,
logicalname,
}
return this.authRequest<ReqRes.PostAppBackupCreateRes>({ method: Method.POST, url: `/apps/${appId}/backup`, data, readTimeout: 30000 })
.then(() => this.appModel.update({ id: appId, status: AppStatus.CREATING_BACKUP }))
.then(() => ({ }))
}
async stopAppBackup (appId: string): Promise<Unit> {
return this.authRequest<ReqRes.PostAppBackupStopRes>({ method: Method.POST, url: `/apps/${appId}/backup/stop`, readTimeout: 30000 })
.then(() => this.appModel.update({ id: appId, status: AppStatus.STOPPED }))
.then(() => ({ }))
}
async restoreAppBackup (appId: string, logicalname: string, password?: string): Promise<Unit> {
const data: ReqRes.PostAppBackupRestoreReq = {
password: password || undefined,
logicalname,
}
return this.authRequest<ReqRes.PostAppBackupRestoreRes>({ method: Method.POST, url: `/apps/${appId}/backup/restore`, data, readTimeout: 30000 })
.then(() => this.appModel.update({ id: appId, status: AppStatus.RESTORING_BACKUP }))
.then(() => ({ }))
}
async patchAppConfig (app: AppInstalledPreview, config: object, dryRun = false): Promise<{ breakages: DependentBreakage[] }> {
const data: ReqRes.PatchAppConfigReq = {
config,
}
return this.authRequest({ method: Method.PATCH, url: `/apps/${app.id}/config${dryRunParam(dryRun, true)}`, data, readTimeout: 30000 })
}
async postConfigureDependency (dependencyId: string, dependentId: string, dryRun?: boolean): Promise<{ config: object, breakages: DependentBreakage[] }> {
return this.authRequest({ method: Method.POST, url: `/apps/${dependencyId}/autoconfig/${dependentId}${dryRunParam(dryRun, true)}`, readTimeout: 30000 })
}
async patchServerConfig (attr: string, value: any): Promise<Unit> {
const data: ReqRes.PatchServerConfigReq = {
value,
}
return this.authRequest({ method: Method.PATCH, url: `/${attr}`, data, readTimeout: 30000 })
.then(() => this.serverModel.update({ [attr]: value }))
.then(() => ({ }))
}
async wipeAppData (app: AppInstalledPreview): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: `/apps/${app.id}/wipe`, readTimeout: 30000 }).then((res) => {
this.appModel.update({ id: app.id, status: AppStatus.NEEDS_CONFIG })
return res
})
}
async addSSHKey (sshKey: string): Promise<Unit> {
const data: ReqRes.PostAddSSHKeyReq = {
sshKey,
}
const fingerprint = await this.authRequest<ReqRes.PostAddSSHKeyRes>({ method: Method.POST, url: `/sshKeys`, data })
this.serverModel.update({ ssh: [...this.serverModel.peek().ssh, fingerprint] })
return { }
}
async addWifi (ssid: string, password: string, country: string, connect: boolean): Promise<Unit> {
const data: ReqRes.PostAddWifiReq = {
ssid,
password,
country,
skipConnect: !connect,
}
return this.authRequest({ method: Method.POST, url: `/wifi`, data })
}
async connectWifi (ssid: string): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: encodeURI(`/wifi/${ssid}`) })
}
async deleteWifi (ssid: string): Promise<Unit> {
return this.authRequest({ method: Method.DELETE, url: encodeURI(`/wifi/${ssid}`) })
}
async deleteSSHKey (fingerprint: SSHFingerprint): Promise<Unit> {
await this.authRequest({ method: Method.DELETE, url: `/sshKeys/${fingerprint.hash}` })
const ssh = this.serverModel.peek().ssh
this.serverModel.update({ ssh: ssh.filter(s => s !== fingerprint) })
return { }
}
async restartServer (): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: '/restart', readTimeout: 30000 })
}
async shutdownServer (): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: '/shutdown', readTimeout: 30000 })
}
private async authRequest<T> (opts: HttpOptions, overrides: Partial<{ version: string }> = { }): Promise<T> {
if (!this.authenticatedRequestsEnabled) throw new Error(`Authenticated requests are not enabled. Do you need to login?`)
opts.withCredentials = true
return this.http.serverRequest<T>(opts, overrides).catch((e: HttpError) => {
console.log(`Got a server error!`, e)
if (isUnauthorized(e)) this.received401()
throw e
})
}
}
type HttpError = HttpErrorResponse & { error: { code: string, message: string } }
const dryRunParam = (dryRun: boolean, first: boolean) => {
if (!dryRun) return ''
return first ? `?dryrun` : `&dryrun`
}

View File

@@ -0,0 +1,477 @@
# Size Limit [![Cult Of Martians][cult-img]][cult]
<img src="https://ai.github.io/size-limit/logo.svg" align="right"
alt="Size Limit logo by Anton Lovchikov" width="120" height="178">
Size Limit is a performance budget tool for JavaScript. It checks every commit
on CI, calculates the real cost of your JS for end-users and throws an error
if the cost exceeds the limit.
* **ES modules** and **tree-shaking** support.
* Add Size Limit to **Travis CI**, **Circle CI**, **GitHub Actions**
or another CI system to know if a pull request adds a massive dependency.
* **Modular** to fit different use cases: big JS applications
that use their own bundler or small npm libraries with many files.
* Can calculate **the time** it would take a browser
to download and **execute** your JS. Time is a much more accurate
and understandable metric compared to the size in bytes.
* Calculations include **all dependencies and polyfills**
used in your JS.
<p align="center">
<img src="./img/example.png" alt="Size Limit CLI" width="738">
</p>
With **[GitHub action]** Size Limit will post bundle size changes as a comment
in pull request discussion.
<p align="center">
<img src="https://raw.githubusercontent.com/andresz1/size-limit-action/master/assets/pr.png"
alt="Size Limit comment in pull request about bundle size changes"
width="686" height="289">
</p>
With `--why`, Size Limit can tell you *why* your library is of this size
and show the real cost of all your internal dependencies.
<p align="center">
<img src="./img/why.png" alt="Bundle Analyzer example" width="650">
</p>
<p align="center">
<a href="https://evilmartians.com/?utm_source=size-limit">
<img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg"
alt="Sponsored by Evil Martians" width="236" height="54">
</a>
</p>
[GitHub action]: https://github.com/andresz1/size-limit-action
[cult-img]: http://cultofmartians.com/assets/badges/badge.svg
[cult]: http://cultofmartians.com/tasks/size-limit-config.html
## Who Uses Size Limit
* [MobX](https://github.com/mobxjs/mobx)
* [Material-UI](https://github.com/callemall/material-ui)
* [Autoprefixer](https://github.com/postcss/autoprefixer)
* [PostCSS](https://github.com/postcss/postcss) reduced
[25% of the size](https://github.com/postcss/postcss/commit/150edaa42f6d7ede73d8c72be9909f0a0f87a70f).
* [Browserslist](https://github.com/ai/browserslist) reduced
[25% of the size](https://github.com/ai/browserslist/commit/640b62fa83a20897cae75298a9f2715642531623).
* [EmojiMart](https://github.com/missive/emoji-mart) reduced
[20% of the size](https://github.com/missive/emoji-mart/pull/111)
* [nanoid](https://github.com/ai/nanoid) reduced
[33% of the size](https://github.com/ai/nanoid/commit/036612e7d6cc5760313a8850a2751a5e95184eab).
* [React Focus Lock](https://github.com/theKashey/react-focus-lock) reduced
[32% of the size](https://github.com/theKashey/react-focus-lock/pull/48).
* [Logux](https://github.com/logux) reduced
[90% of the size](https://github.com/logux/logux-client/commit/62b258e20e1818b23ae39b9c4cd49e2495781e91).
## How It Works
1. Size Limit contains a CLI tool, 3 plugins (`file`, `webpack`, `time`)
and 3 plugin presets for popular use cases (`app`, `big-lib`, `small-lib`).
A CLI tool finds plugins in `package.json` and loads the config.
2. If you use the `webpack` plugin, Size Limit will bundle your JS files into
a single file. It is important to track dependencies and webpack polyfills.
It is also useful for small libraries with many small files and without
a bundler.
3. The `webpack` plugin creates an empty webpack project, adds your library
and looks for the bundle size difference.
4. The `time` plugin compares the current machine performance with that of
a low-priced Android devices to calculate the CPU throttling rate.
5. Then the `time` plugin runs headless Chrome (or desktop Chrome if its
available) to track the time a browser takes to compile and execute your JS.
Note that these measurements depend on available resources and might
be unstable. [See here](https://github.com/mbalabash/estimo/issues/5)
for more details.
## Usage
### JS Applications
Suitable for applications that have their own bundler and send the JS bundle
directly to a client (without publishing it to npm). Think of a user-facing app
or website, like an email client, a CRM, a landing page or a blog with
interactive elements, using React/Vue/Svelte lib or vanilla JS.
<details><summary><b>Show instructions</b></summary>
1. Install the preset:
```sh
$ npm install --save-dev size-limit @size-limit/preset-app
```
2. Add the `size-limit` section and the `size` script to your `package.json`:
```diff
+ "size-limit": [
+ {
+ "path": "dist/app-*.js"
+ }
+ ],
"scripts": {
"build": "webpack ./webpack.config.js",
+ "size": "npm run build && size-limit",
"test": "jest && eslint ."
}
```
3. Heres how you can get the size for your current project:
```sh
$ npm run size
Package size: 30.08 KB with all dependencies, minified and gzipped
Loading time: 602 ms on slow 3G
Running time: 214 ms on Snapdragon 410
Total time: 815 ms
```
4. Now, lets set the limit. Add 25% to the current total time and use that as
the limit in your `package.json`:
```diff
"size-limit": [
{
+ "limit": "1 s",
"path": "dist/app-*.js"
}
],
```
5. Add the `size` script to your test suite:
```diff
"scripts": {
"build": "webpack ./webpack.config.js",
"size": "npm run build && size-limit",
- "test": "jest && eslint ."
+ "test": "jest && eslint . && npm run size"
}
```
6. If you dont have a continuous integration service running, dont forget
to add one — start with [Travis CI].
</details>
### Big Libraries
JS libraries > 10 KB in size.
This preset includes headless Chrome, and will measure your libs execution
time. You likely dont need this overhead for a small 2 KB lib, but for larger
ones the execution time is a more accurate and understandable metric that
the size in bytes. Library like [React] is a good example for this preset.
<details><summary><b>Show instructions</b></summary>
1. Install preset:
```sh
$ npm install --save-dev size-limit @size-limit/preset-big-lib
```
2. Add the `size-limit` section and the `size` script to your `package.json`:
```diff
+ "size-limit": [
+ {
+ "path": "dist/react.production-*.js"
+ }
+ ],
"scripts": {
"build": "webpack ./scripts/rollup/build.js",
+ "size": "npm run build && size-limit",
"test": "jest && eslint ."
}
```
3. If you use ES modules you can test the size after tree-shaking with `import`
option:
```diff
"size-limit": [
{
"path": "dist/react.production-*.js",
+ "import": "{ createComponent }"
}
],
```
4. Heres how you can get the size for your current project:
```sh
$ npm run size
Package size: 30.08 KB with all dependencies, minified and gzipped
Loading time: 602 ms on slow 3G
Running time: 214 ms on Snapdragon 410
Total time: 815 ms
```
5. Now, lets set the limit. Add 25% to the current total time and use that
as the limit in your `package.json`:
```diff
"size-limit": [
{
+ "limit": "1 s",
"path": "dist/react.production-*.js"
}
],
```
6. Add a `size` script to your test suite:
```diff
"scripts": {
"build": "rollup ./scripts/rollup/build.js",
"size": "npm run build && size-limit",
- "test": "jest && eslint ."
+ "test": "jest && eslint . && npm run size"
}
```
7. If you dont have a continuous integration service running, dont forget
to add one — start with [Travis CI].
8. Add the library size to docs, it will help users to choose your project:
```diff
# Project Name
Short project description
* **Fast.** 10% faster than competitor.
+ * **Small.** 15 KB (minified and gzipped).
+ [Size Limit](https://github.com/ai/size-limit) controls the size.
```
</details>
### Small Libraries
JS libraries < 10 KB in size.
This preset will only measure the size, without the execution time, so its
suitable for small libraries. If your library is larger, you likely want
the Big Libraries preset above. [Nano ID] or [Storeon] are good examples
for this preset.
<details><summary><b>Show instructions</b></summary>
1. First, install `size-limit`:
```sh
$ npm install --save-dev size-limit @size-limit/preset-small-lib
```
2. Add the `size-limit` section and the `size` script to your `package.json`:
```diff
+ "size-limit": [
+ {
+ "path": "index.js"
+ }
+ ],
"scripts": {
+ "size": "size-limit",
"test": "jest && eslint ."
}
```
3. Heres how you can get the size for your current project:
```sh
$ npm run size
Package size: 177 B with all dependencies, minified and gzipped
```
4. If your project size starts to look bloated, run `--why` for analysis:
```sh
$ npm run size -- --why
```
5. Now, lets set the limit. Determine the current size of your library,
add just a little bit (a kilobyte, maybe) and use that as the limit
in your `package.json`:
```diff
"size-limit": [
{
+ "limit": "9 KB",
"path": "index.js"
}
],
```
6. Add the `size` script to your test suite:
```diff
"scripts": {
"size": "size-limit",
- "test": "jest && eslint ."
+ "test": "jest && eslint . && npm run size"
}
```
7. If you dont have a continuous integration service running, dont forget
to add one — start with [Travis CI].
8. Add the library size to docs, it will help users to choose your project:
```diff
# Project Name
Short project description
* **Fast.** 10% faster than competitor.
+ * **Small.** 500 bytes (minified and gzipped). No dependencies.
+ [Size Limit](https://github.com/ai/size-limit) controls the size.
```
</details>
[Travis CI]: https://github.com/dwyl/learn-travis
[Storeon]: https://github.com/ai/storeon/
[Nano ID]: https://github.com/ai/nanoid/
[React]: https://github.com/facebook/react/
## Reports
Size Limit has a [GitHub action] that comments and rejects pull requests based
on Size Limit output.
1. Install and configure Size Limit as shown above.
2. Add the following action inside `.github/workflows/size-limit.yml`
```yaml
name: "size"
on:
pull_request:
branches:
- master
jobs:
size:
runs-on: ubuntu-latest
env:
CI_JOB_NUMBER: 1
steps:
- uses: actions/checkout@v1
- uses: andresz1/size-limit-action@v1.0.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
```
## Config
Size Limits supports three ways to define config.
1. `size-limit` section in `package.json`:
```json
"size-limit": [
{
"path": "index.js",
"import": "{ createStore }",
"limit": "500 ms"
}
]
```
2. or a separate `.size-limit.json` config file:
```js
[
{
"path": "index.js",
"import": "{ createStore }",
"limit": "500 ms"
}
]
```
3. or a more flexible `.size-limit.js` config file:
```js
module.exports = [
{
path: "index.js",
import: "{ createStore }",
limit: "500 ms"
}
]
```
Each section in the config can have these options:
* **path**: relative paths to files. The only mandatory option.
It could be a path `"index.js"`, a [pattern] `"dist/app-*.js"`
or an array `["index.js", "dist/app-*.js", "!dist/app-exclude.js"]`.
* **import**: partial import to test tree-shaking. It could be `"{ lib }"`
to test `import { lib } from 'lib'` or `{ "a.js": "{ a }", "b.js": "{ b }" }`
to test multiple files.
* **limit**: size or time limit for files from the `path` option. It should be
a string with a number and unit, separated by a space.
Format: `100 B`, `10 KB`, `500 ms`, `1 s`.
* **name**: the name of the current section. It will only be useful
if you have multiple sections.
* **entry**: when using a custom webpack config, a webpack entry could be given.
It could be a string or an array of strings.
By default, the total size of all entry points will be checked.
* **webpack**: with `false` it will disable webpack.
* **running**: with `false` it will disable calculating running time.
* **gzip**: with `false` it will disable gzip compression.
* **brotli**: with `true` it will use brotli compression and disable gzip compression.
* **config**: a path to a custom webpack config.
* **ignore**: an array of files and dependencies to exclude from
the project size calculation.
If you use Size Limit to track the size of CSS files, make sure to set
`webpack: false`. Otherwise, you will get wrong numbers, because webpack
inserts `style-loader` runtime (≈2 KB) into the bundle.
[pattern]: https://github.com/sindresorhus/globby#globbing-patterns
## Plugins and Presets
Plugins:
* `@size-limit/file` checks the size of files with Gzip, Brotli
or without compression.
* `@size-limit/webpack` adds your library to empty webpack project
and prepares bundle file for `file` plugin.
* `@size-limit/time` uses headless Chrome to track time to execute JS.
* `@size-limit/dual-publish` compiles files to ES modules with [`dual-publish`]
to check size after tree-shaking.
Plugin presets:
* `@size-limit/preset-app` contains `file` and `time` plugins.
* `@size-limit/preset-big-lib` contains `webpack`, `file`, and `time` plugins.
* `@size-limit/preset-small-lib` contains `webpack` and `file` plugins.
[`dual-publish`]: https://github.com/ai/dual-publish
## JS API
```js
const sizeLimit = require('size-limit')
const filePlugin = require('@size-limit/file')
const webpackPlugin = require('@size-limit/webpack')
sizeLimit([filePlugin, webpackPlugin], [filePath]).then(result => {
result //=> { size: 12480 }
})
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,289 @@
import { AppStatus } from '../../models/app-model'
import { AppAvailablePreview, AppAvailableFull, AppInstalledPreview, AppDependency, BaseApp, AppInstalledFull, DependentBreakage, AppAvailableVersionSpecificInfo } from '../../models/app-types'
export function toAvailablePreview (f: AppAvailableFull): AppAvailablePreview {
return {
id: f.id,
versionInstalled: f.versionInstalled,
status: f.status,
title: f.title,
descriptionShort: f.descriptionShort,
iconURL: f.iconURL,
versionLatest: f.versionLatest,
}
}
export function toInstalledPreview (f: AppInstalledFull): AppInstalledPreview {
return {
id: f.id,
versionInstalled: f.versionInstalled,
status: f.status,
title: f.title,
iconURL: f.iconURL,
torAddress: f.torAddress,
}
}
export function toServiceRequirement (f: BaseApp, o: Omit<AppDependency, keyof BaseApp>): AppDependency {
return {
id: f.id,
title: f.title,
iconURL: f.iconURL,
...o,
}
}
export function toServiceBreakage (f: BaseApp): DependentBreakage {
return {
id: f.id,
title: f.title,
iconURL: f.iconURL,
}
}
export const bitcoinI: AppInstalledFull = {
id: 'bitcoind',
versionInstalled: '0.18.1',
title: 'Bitcoin Core',
torAddress: 'sample-bitcoin-tor-address-and-some-more-tor-address.onion',
status: AppStatus.STOPPED,
iconURL: 'assets/img/service-icons/bitcoind.png',
instructions: 'some instructions',
lastBackup: new Date().toISOString(),
configuredRequirements: [],
hasFetchedFull: true,
}
export const lightningI: AppInstalledFull = {
id: 'c-lightning',
status: AppStatus.RUNNING,
title: 'C Lightning',
versionInstalled: '1.0.0',
torAddress: 'sample-bitcoin-tor-address-and-some-more-tor-address.onion',
iconURL: 'assets/img/service-icons/bitwarden.png',
instructions: 'some instructions',
lastBackup: new Date().toISOString(),
configuredRequirements: [
toServiceRequirement(bitcoinI,
{
optional: 'you don\'t reeeeelly need this',
default: true,
versionSpec: '>= 0.1.2',
description: 'lightning needs bitcoin',
violation: null,
}),
],
hasFetchedFull: true,
}
export const cupsI: AppInstalledFull = {
id: 'cups',
versionInstalled: '2.1.0',
title: 'Cups Messenger',
torAddress: 'sample-cups-tor-address.onion',
status: AppStatus.BROKEN_DEPENDENCIES,
iconURL: 'assets/img/service-icons/cups.png',
instructions: 'some instructions',
lastBackup: new Date().toISOString(),
configuredRequirements: [
toServiceRequirement(lightningI,
{
optional: 'you don\'t reeeeelly need this',
default: true,
versionSpec: '>= 0.1.2',
description: 'lightning needs bitcoin',
violation: { name: 'incompatible-version' },
}),
toServiceRequirement(lightningI,
{
optional: 'you don\'t reeeeelly need this',
default: true,
versionSpec: '>= 0.1.2',
description: 'lightning needs bitcoin',
violation: { name: 'incompatible-status', status: AppStatus.INSTALLING },
}),
toServiceRequirement(lightningI,
{
optional: 'you don\'t reeeeelly need this',
default: true,
versionSpec: '>= 0.1.2',
description: 'lightning needs bitcoin',
violation: { name: 'incompatible-config', ruleViolations: ['bro', 'seriously', 'fix this'] },
}),
],
hasFetchedFull: true,
}
export const bitcoinA: AppAvailableFull = {
id: 'bitcoind',
versionLatest: '0.19.1.1',
versionInstalled: '0.19.0',
status: AppStatus.UNKNOWN,
title: 'Bitcoin Core',
descriptionShort: 'Bitcoin is an innovative payment network and new kind of money.',
iconURL: 'assets/img/service-icons/bitcoind.png',
releaseNotes: 'Bitcoin is an innovative payment network and new kind of money. Bitcoin utilizes a robust p2p network to garner decentralized consensus. Bitcoin is an innovative payment network and new kind of money. Bitcoin utilizes a robust p2p network to garner decentralized consensus. Bitcoin is an innovative payment network and new kind of money. Bitcoin utilizes a robust p2p network to garner decentralized consensus. Segit and more cool things!',
descriptionLong: 'Bitcoin is an innovative payment network and new kind of money. Bitcoin utilizes a robust p2p network to garner decentralized consensus.',
versions: ['0.19.1.1', '0.19.1', '0.19.0', '0.18.1', '0.17.0'],
versionViewing: '0.19.1',
serviceRequirements: [],
}
export const lightningA: AppAvailableFull = {
id: 'c-lightning',
versionLatest: '1.0.1',
versionInstalled: null,
status: AppStatus.UNKNOWN,
title: 'C Lightning',
descriptionShort: 'Lightning is quick money things.',
iconURL: 'assets/img/service-icons/bitcoind.png',
releaseNotes: 'Finally it works',
descriptionLong: 'Lightning is an innovative payment network and new kind of money. Lightning utilizes a robust p2p network to garner decentralized consensus.',
versions: ['0.0.1', '0.8.0', '0.8.1', '1.0.0', '1.0.1'],
versionViewing: '1.0.1',
serviceRequirements: [
toServiceRequirement(bitcoinA, {
optional: null,
default: true,
versionSpec: '>=0.19.0',
description: 'Lightning uses bitcoin under the hood',
violation: null,
}),
],
}
export const btcPayA: AppAvailableFull = {
id: 'btcPay',
versionLatest: '1.0.1',
versionInstalled: '1.0.1',
status: AppStatus.INSTALLING,
title: 'BTC Pay',
descriptionShort: 'BTC Pay is quick payment money things',
iconURL: 'assets/img/service-icons/bitcoind.png',
releaseNotes: 'Finally pay us Finally pay us Finally pay us Finally pay us Finally pay usFinally pay us',
descriptionLong: 'Btc Pay is an innovative payment network and new kind of money. Btc Pay utilizes a robust p2p network to garner decentralized consensus.',
versions: ['0.8.0', '0.8.1', '1.0.0', '1.0.1'],
versionViewing: '1.0.1',
serviceRequirements: [
toServiceRequirement(bitcoinA, {
optional: null,
default: true,
versionSpec: '>0.19.0',
description: 'Lightning uses bitcoin under the hood',
violation: { name: 'incompatible-version' },
}),
],
}
export const thunderA: AppAvailableFull = {
id: 'thunder',
versionLatest: '1.0.1',
versionInstalled: null,
status: AppStatus.UNKNOWN,
title: 'Thunder',
descriptionShort: 'Thunder is quick payment money things',
iconURL: 'assets/img/service-icons/bitcoind.png',
releaseNotes: 'Finally pay us',
descriptionLong: 'Thunder is an innovative payment network and new kind of money. Thunder utilizes a robust p2p network to garner decentralized consensus.',
versions: ['0.8.0', '0.8.1', '1.0.0', '1.0.1'],
versionViewing: '1.0.1',
serviceRequirements: [
toServiceRequirement(bitcoinA, {
optional: null,
default: true,
versionSpec: '>0.19.0',
description: 'Thunder uses bitcoin under the hood',
violation: { name: 'incompatible-version' },
}),
toServiceRequirement(lightningA, {
optional: null,
default: true,
versionSpec: '>=1.0.1',
description: 'Thunder uses lightning under the hood',
violation: { name: 'incompatible-version' },
}),
toServiceRequirement(btcPayA, {
optional: 'Can be configured to use chase bank instead',
default: true,
versionSpec: '>=1.0.1',
description: 'Thunder can use btcpay under the hood',
violation: { name: 'missing' },
}),
toServiceRequirement(btcPayA, {
optional: 'Can be configured to use chase bank instead',
default: true,
versionSpec: '>=1.0.1',
description: 'Thunder can use btcpay under the hood',
violation: { name: 'incompatible-status', status: AppStatus.INSTALLING },
}),
],
}
export const cupsA: AppAvailableFull = {
id: 'cups',
versionLatest: '2.1.0',
versionInstalled: '2.1.0',
status: AppStatus.RUNNING,
title: 'Cups Messenger',
descriptionShort: 'P2P encrypted messaging over Tor.',
iconURL: 'assets/img/service-icons/cups.png',
releaseNotes: 'Segit and more cool things!',
descriptionLong: 'Bitcoin is an innovative payment network and new kind of money. Bitcoin utilizes a robust p2p network to garner decentralized consensus.',
versions: ['0.1.0', '0.1.1', '0.1.2', '1.0.0', '2.0.0', '2.1.0'],
versionViewing: '2.1.0',
serviceRequirements: [],
}
export const bitwardenA: AppAvailableFull = {
id: 'bitwarden',
versionLatest: '0.1.1',
versionInstalled: null,
status: null,
title: 'Bitwarden',
descriptionShort: `Self-hosted password manager`,
iconURL: 'assets/img/service-icons/bitwarden.png',
releaseNotes: 'Passwords and shite!',
descriptionLong: 'Bitwarden is fun.',
versions: ['0.19.0', '0.18.1', '0.17.0'],
versionViewing: '0.1.1',
serviceRequirements: [
toServiceRequirement(cupsA, {
optional: 'Can be configured to use chase bank instead',
default: true,
versionSpec: '>=1.0.0',
description: 'cups does great stuff for bitwarden',
violation: { name: 'incompatible-config', ruleViolations: ['change this value to that value', 'change this second value to something better']},
}),
],
}
export const mockApiAppAvailableFull: { [appId: string]: AppAvailableFull; } = {
bitcoind: bitcoinA,
lightning: lightningA,
btcPay: btcPayA,
thunder: thunderA,
cups: cupsA,
bitwarden: bitwardenA,
}
export const mockApiAppInstalledFull: { [appId: string]: AppInstalledFull; } = {
bitcoind: bitcoinI,
cups: cupsI,
lightning: lightningI,
}
export const mockApiAppAvailableVersionInfo: AppAvailableVersionSpecificInfo = {
releaseNotes: 'Some older release notes that are not super important anymore.',
serviceRequirements: [],
versionViewing: '0.2.0',
}
export const mockAppDependentBreakages: { breakages: DependentBreakage[] } = {
breakages: [
toServiceBreakage(bitcoinI),
toServiceBreakage(cupsA),
],
}

View File

@@ -0,0 +1,62 @@
import { Injectable } from '@angular/core'
import { BehaviorSubject, Subscription } from 'rxjs'
import { distinctUntilChanged } from 'rxjs/operators'
import { ApiService } from './api/api.service'
import { chill } from '../util/misc.util'
import { isUnauthorized } from '../util/web.util'
import { Storage } from '@ionic/storage'
import { StorageKeys } from '../models/storage-keys'
export enum AuthState {
UNVERIFIED,
VERIFIED,
INITIALIZING,
}
@Injectable({
providedIn: 'root',
})
export class AuthService {
private readonly $authState$: BehaviorSubject<AuthState> = new BehaviorSubject(AuthState.INITIALIZING)
constructor (
private readonly api: ApiService,
private readonly storage: Storage,
) { }
peek (): AuthState { return this.$authState$.getValue() }
listen (callback: Partial<{ [k in AuthState]: () => any }>): Subscription {
return this.$authState$.pipe(distinctUntilChanged()).subscribe(s => {
return (callback[s] || chill)()
})
}
async login (password: string) {
try {
await this.api.postLogin(password)
await this.storage.set(StorageKeys.LOGGED_IN_KEY, true)
this.$authState$.next(AuthState.VERIFIED)
} catch (e) {
if (isUnauthorized(e)) {
this.$authState$.next(AuthState.UNVERIFIED)
throw { name: 'invalid', message: 'invalid credentials' }
}
console.error(`Failed login attempt`, e)
throw e
}
}
async restoreCache (): Promise<AuthState> {
const loggedIn = await this.storage.get(StorageKeys.LOGGED_IN_KEY)
if (loggedIn) {
this.$authState$.next(AuthState.VERIFIED)
return AuthState.VERIFIED
} else {
this.$authState$.next(AuthState.UNVERIFIED)
return AuthState.UNVERIFIED
}
}
async setAuthStateUnverified (): Promise<void> {
this.$authState$.next(AuthState.UNVERIFIED)
}
}

View File

@@ -0,0 +1,33 @@
import { Injectable } from '@angular/core'
@Injectable({
providedIn: 'root',
})
export class ConfigService {
origin = removePort(removeProtocol(window.origin))
version = require('../../../package.json').version
api = {
useMocks: require('../../../use-mocks.json').useMocks,
url: '/api',
version: '/v0',
root: '', // empty will default to same origin
}
isConsulateIos = window['platform'] === 'ios'
isConsulateAndroid = window['platform'] === 'android'
isTor () : boolean {
return this.api.useMocks || this.origin.endsWith('.onion')
}
}
function removeProtocol (str: string): string {
if (str.startsWith('http://')) return str.slice(7)
if (str.startsWith('https://')) return str.slice(8)
return str
}
function removePort (str: string): string {
return str.split(':')[0]
}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@angular/core'
@Injectable({
providedIn: 'root',
})
export class Emver {
private e: typeof import('@start9labs/emver')
constructor () { }
async init () {
this.e = await import('@start9labs/emver')
}
compare (lhs: string, rhs: string): number {
return this.e.compare(lhs, rhs)
}
satisfies (version: string, range: string): boolean {
return this.e.satisfies(version, range)
}
}

View File

@@ -0,0 +1,118 @@
import { Injectable } from '@angular/core'
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
import { Observable, from, interval, race } from 'rxjs'
import { map, take } from 'rxjs/operators'
import { ConfigService } from './config.service'
@Injectable({
providedIn: 'root',
})
export class HttpService {
constructor (
private readonly http: HttpClient,
private readonly config: ConfigService,
) { }
async serverRequest<T> (options: HttpOptions, overrides: Partial<{ version: string }> = { }): Promise<T> {
options.url = leadingSlash(`${this.config.api.url}${exists(overrides.version) ? overrides.version : this.config.api.version}${options.url}`)
if ( this.config.api.root && this.config.api.root !== '' ) {
options.url = `${this.config.api.root}${options.url}`
}
return this.request<T>(options)
}
async request<T> (httpOpts: HttpOptions): Promise<T> {
const { url, body, timeout, ...rest} = translateOptions(httpOpts)
let req: Observable<{ body: T }>
switch (httpOpts.method){
case Method.GET: req = this.http.get(url, rest) as any; break
case Method.POST: req = this.http.post(url, body, rest) as any; break
case Method.PUT: req = this.http.put(url, body, rest) as any; break
case Method.PATCH: req = this.http.patch(url, body, rest) as any; break
case Method.DELETE: req = this.http.delete(url, rest) as any; break
}
return (timeout ? withTimeout(req, timeout) : req)
.toPromise()
.then(res => res.body)
.catch(e => { console.error(e); throw humanReadableErrorMessage(e)})
}
}
function humanReadableErrorMessage (e: any): Error {
// server up, custom backend error
if (e.error && e.error.message) return { ...e, message: e.error.message }
if (e.message) return { ...e, message: e.message }
if (e.status && e.statusText) return { ...e, message: `${e.status} ${e.statusText}` }
return { ...e, message: `Unidentifiable HTTP exception` }
}
function leadingSlash (url: string): string {
let toReturn = url
toReturn = toReturn.startsWith('/') ? toReturn : '/' + toReturn
toReturn = !toReturn.endsWith('/') ? toReturn : toReturn.slice(0, -1)
return toReturn
}
export enum Method {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
PATCH = 'PATCH',
DELETE = 'DELETE',
}
export interface HttpOptions {
withCredentials?: boolean
url: string
method: Method
params?: {
[param: string]: string | string[];
}
data?: any
headers?: {
[key: string]: string;
}
readTimeout?: number
}
export interface HttpJsonOptions {
headers?: HttpHeaders | {
[header: string]: string | string[];
}
observe: 'events'
params?: HttpParams | {
[param: string]: string | string[];
}
reportProgress?: boolean
responseType?: 'json'
withCredentials?: boolean
body?: any
url: string
timeout: number
}
function translateOptions (httpOpts: HttpOptions): HttpJsonOptions {
return {
observe: 'events',
responseType: 'json',
reportProgress: false,
withCredentials: true,
headers: httpOpts.headers,
params: httpOpts.params,
body: httpOpts.data || { },
url: httpOpts.url,
timeout: httpOpts.readTimeout,
}
}
function withTimeout<U> (req: Observable<U>, timeout: number): Observable<U> {
return race(
from(req.toPromise()), // this guarantees it only emits on completion, intermediary emissions are suppressed.
interval(timeout).pipe(take(1), map(() => { throw new Error('timeout') })),
)
}
function exists (str?: string): boolean {
return !!str || str === ''
}

View File

@@ -0,0 +1,86 @@
import { Injectable } from '@angular/core'
import { concatMap, finalize } from 'rxjs/operators'
import { Observable, from, Subject } from 'rxjs'
import { fromAsync$, fromAsyncP, emitAfter$, fromSync$ } from '../util/rxjs.util'
import { LoadingController } from '@ionic/angular'
import { LoadingOptions } from '@ionic/core'
@Injectable({
providedIn: 'root',
})
export class LoaderService {
private loadingOptions: LoadingOptions = defaultOptions()
constructor (private readonly loadingCtrl: LoadingController) { }
private loader: HTMLIonLoadingElement
public get ionLoader (): HTMLIonLoadingElement {
return this.loader
}
public get ctrl () {
return this.loadingCtrl
}
private setOptions (l: LoadingOptions): LoaderService {
this.loadingOptions = l
return this
}
of (overrideOptions: LoadingOptions): LoaderService {
return new LoaderService(this.loadingCtrl).setOptions(Object.assign(defaultOptions(), overrideOptions))
}
displayDuring$<T> (o: Observable<T>): Observable<T> {
let shouldDisplay = true
const displayIfItsBeenAtLeast = 10 // ms
return fromAsync$(
async () => {
this.loader = await this.loadingCtrl.create(this.loadingOptions)
emitAfter$(displayIfItsBeenAtLeast).subscribe(() => { if (shouldDisplay) this.loader.present() })
},
).pipe(
concatMap(() => o),
finalize(() => {
this.loader.dismiss(); shouldDisplay = false; this.loader = undefined
}),
)
}
displayDuringP<T> (p: Promise<T>): Promise<T> {
return this.displayDuring$(from(p)).toPromise()
}
displayDuringAsync<T> (thunk: () => Promise<T>): Promise<T> {
return this.displayDuringP(fromAsyncP(thunk))
}
}
export function markAsLoadingDuring$<T> ($trigger$: Subject<boolean>, o: Observable<T>): Observable<T> {
let shouldBeOn = true
const displayIfItsBeenAtLeast = 5 // ms
return fromSync$(() => {
emitAfter$(displayIfItsBeenAtLeast).subscribe(() => { if (shouldBeOn) $trigger$.next(true) })
}).pipe(
concatMap(() => o),
finalize(() => {
$trigger$.next(false)
shouldBeOn = false
}),
)
}
export function markAsLoadingDuringP<T> ($trigger$: Subject<boolean>, p: Promise<T>): Promise<T> {
return markAsLoadingDuring$($trigger$, from(p)).toPromise()
}
export function markAsLoadingDuringAsync<T> ($trigger$: Subject<boolean>, thunk: () => Promise<T>): Promise<T> {
return markAsLoadingDuringP($trigger$, fromAsyncP(thunk))
}
const defaultOptions: () => LoadingOptions = () => ({
spinner: 'lines',
cssClass: 'loader',
backdropDismiss: true,
})

View File

@@ -0,0 +1,23 @@
import { Router } from '@angular/router'
import { Injectable } from '@angular/core'
import { NavController } from '@ionic/angular'
@Injectable({
providedIn: 'root',
})
export class PwaBackService {
constructor (
private readonly router: Router,
private readonly nav: NavController,
) { }
// this will strip an entry from the path on navigation
back () {
return this.nav.back()
// this.router.navigate()
// const path = this.router.url.split('/').filter(a => a !== '')
// path.pop()
// this.router.navigate(['/', ...path], { replaceUrl: false })
}
}

View File

@@ -0,0 +1,114 @@
import { Injectable } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { AppConfigValuePage } from '../modals/app-config-value/app-config-value.page'
import { ApiService } from './api/api.service'
import { PropertySubject } from '../util/property-subject.util'
import { S9Server, ServerModel } from '../models/server-model'
import { ValueSpec } from '../app-config/config-types'
@Injectable({
providedIn: 'root',
})
export class ServerConfigService {
server: PropertySubject<S9Server>
constructor (
private readonly modalCtrl: ModalController,
private readonly apiService: ApiService,
private readonly serverModel: ServerModel,
) {
this.server = this.serverModel.watch()
}
async presentModalValueEdit (key: string, add = false) {
const modal = await this.modalCtrl.create({
backdropDismiss: false,
component: AppConfigValuePage,
presentingElement: await this.modalCtrl.getTop(),
componentProps: {
...this.getConfigSpec(key),
value: add ? '' : this.server[key].getValue(),
},
})
await modal.present()
}
private getConfigSpec (key: string): SpecAndSaveFn {
const configSpec: { [key: string]: SpecAndSaveFn } = {
name: {
spec: {
type: 'string',
name: 'Device Name',
description: 'A unique label for this device.',
nullable: false,
// @TODO determine regex
// pattern: '',
patternDescription: 'Must be less than 40 characters',
masked: false,
copyable: true,
},
saveFn: (val: string) => {
return this.apiService.patchServerConfig('name', val).then(() => this.serverModel.update({ name: val }))
},
},
// password: {
// spec: {
// type: 'string',
// name: 'Change Password',
// description: 'The master password for your Embassy. Must contain at least 128 bits of entropy.',
// nullable: false,
// // @TODO figure out how to confirm min entropy
// // pattern: '^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[*.!@#$%^&*\]).{12,32}$',
// patternDescription: 'Password too simple. Password must contain at least 128 bits of entroy.',
// changeWarning: 'Changing your password will have no affect on old backups. In order to restore old backups, you must provide the password that was used to create them.',
// masked: true,
// copyable: true,
// },
// saveFn: (val: string) => {
// return this.apiService.patchServerConfig('password', val)
// },
// },
// alternativeRegistryUrl: {
// spec: {
// type: 'string',
// name: 'Marketplace URL',
// description: 'Used for connecting to an alternative service marketplace.',
// nullable: true,
// // @TODO regex for URL
// // pattern: '',
// patternDescription: 'Must be a valid URL',
// changeWarning: 'Downloading services from an alternative marketplace could result in malicious or harmful code being installed on your device.',
// masked: false,
// copyable: true,
// },
// saveFn: (val: string) => {
// return this.apiService.patchServerConfig('alternativeRegistryUrl', val).then(() => this.serverModel.update({ alternativeRegistryUrl: val }))
// },
// },
ssh: {
spec: {
type: 'string',
name: 'SSH Key',
description: 'Add SSH keys to your Embassy to gain root access from the command line.',
nullable: false,
// @TODO regex for SSH Key
// pattern: '',
patternDescription: 'Must be a valid SSH key',
masked: true,
copyable: true,
},
saveFn: (val: string) => {
return this.apiService.addSSHKey(val)
},
},
}
return configSpec[key]
}
}
interface SpecAndSaveFn {
spec: ValueSpec
saveFn: (val: string) => Promise<any>
}

View File

@@ -0,0 +1,10 @@
import { BehaviorSubject } from 'rxjs'
import { Injectable } from '@angular/core'
@Injectable({
providedIn: 'root',
})
export class SplitPaneTracker {
$menuFixedOpenOnLeft$: BehaviorSubject<boolean> = new BehaviorSubject(false)
constructor () { }
}

View File

@@ -0,0 +1,50 @@
import { Injectable } from '@angular/core'
import { ToastController, NavController } from '@ionic/angular'
import { ServerModel, S9Server } from '../models/server-model'
@Injectable({
providedIn: 'root',
})
export class SyncNotifier {
constructor (
private readonly toastCtrl: ToastController,
private readonly navCtrl: NavController,
private readonly serverModel: ServerModel,
) { }
async handleNotifications (server: Readonly<S9Server>): Promise<void> {
const count = server.notifications.length
if (!count) { return }
let updates = { } as Partial<S9Server>
updates.badge = server.badge + count
updates.notifications = []
const toast = await this.toastCtrl.create({
header: 'Embassy',
message: `${count} new notification${count === 1 ? '' : 's'}`,
position: 'bottom',
duration: 4000,
cssClass: 'notification-toast',
buttons: [
{
side: 'start',
icon: 'close',
handler: () => {
return true
},
},
{
side: 'end',
text: 'View',
handler: () => {
this.navCtrl.navigateForward(['/notifications'])
},
},
],
})
await toast.present()
this.serverModel.update(updates)
}
}

View File

@@ -0,0 +1,79 @@
import { Injectable } from '@angular/core'
import { ServerModel } from '../models/server-model'
import { ApiService } from './api/api.service'
import { tryAll, pauseFor } from '../util/misc.util'
import { AppModel } from '../models/app-model'
import { SyncNotifier } from './sync.notifier'
import { BehaviorSubject, Observable, of, from, Subject, EMPTY } from 'rxjs'
import { switchMap, concatMap, catchError, delay, tap } from 'rxjs/operators'
@Injectable({
providedIn: 'root',
})
export class SyncDaemon {
private readonly syncInterval = 5000
private readonly $sync$ = new BehaviorSubject(false)
// emits on every successful sync
private readonly $synced$ = new Subject<void>()
constructor (
private readonly apiService: ApiService,
private readonly serverModel: ServerModel,
private readonly appModel: AppModel,
private readonly syncNotifier: SyncNotifier,
) {
this.$sync$.pipe(
switchMap(go => go
? this.sync().pipe(delay(this.syncInterval), tap(() => this.$sync$.next(true)))
: EMPTY,
),
).subscribe()
}
start () { this.$sync$.next(true) }
stop () { this.$sync$.next(false) }
sync (): Observable<void> {
return from(this.getServerAndApps()).pipe(
concatMap(() => this.syncNotifier.handleNotifications(this.serverModel.peek())),
tap(() => this.$synced$.next()),
catchError(e => of(console.error(`Exception in sync service`, e))),
)
}
watchSynced (): Observable<void> {
return this.$synced$.asObservable()
}
private async getServerAndApps (): Promise<void> {
const now = new Date()
const [serverRes, appsRes] = await tryAll([
this.apiService.getServer(),
pauseFor(250).then(() => this.apiService.getInstalledApps()),
])
switch (serverRes.result) {
case 'resolve': {
this.serverModel.update(serverRes.value, now)
break
}
case 'reject': {
console.error(`get server request rejected with`, serverRes.value)
this.serverModel.markUnreachable()
break
}
}
switch (appsRes.result) {
case 'resolve': {
this.appModel.syncCache(appsRes.value, now)
break
}
case 'reject': {
console.error(`get apps request rejected with`, appsRes.value)
this.appModel.markAppsUnreachable()
break
}
}
}
}

View File

@@ -0,0 +1,70 @@
import { Inject, Injectable } from '@angular/core'
import { Observable, Subject } from 'rxjs'
import { ModalController } from '@ionic/angular'
import { ModalOptions } from '@ionic/core'
import { APP_CONFIG_COMPONENT_MAPPING } from '../modals/app-config-injectable/modal-injectable-token'
import { AppConfigComponentMapping } from '../modals/app-config-injectable/modal-injectable-type'
import { ValueSpec } from '../app-config/config-types'
@Injectable({
providedIn: 'root',
})
export class TrackingModalController {
private modals: { [modalId: string] : HTMLIonModalElement} = { }
private readonly $onDismiss$ = new Subject<string>()
private readonly $onCreate$ = new Subject<string>()
constructor (
private readonly modalCtrl: ModalController,
@Inject(APP_CONFIG_COMPONENT_MAPPING) private readonly appConfigComponentMapping: AppConfigComponentMapping,
) { }
async createConfigModal (o: Omit<ModalOptions, 'component'>, type: ValueSpec['type']) {
const component = this.appConfigComponentMapping[type]
return this.create({ ...o, component })
}
async create (a: ModalOptions): Promise<HTMLIonModalElement> {
const modal = await this.modalCtrl.create(a)
this.modals[modal.id] = modal
this.$onCreate$.next(modal.id)
modal.onWillDismiss().then(() => {
delete this.modals[modal.id]
this.$onDismiss$.next(modal.id)
})
return modal
}
dismissAll (): Promise<boolean[]> {
return Promise.all(
Object.values(this.modals).map(m => m.dismiss()),
)
}
dismiss (val?: any): Promise<boolean> {
return this.modalCtrl.dismiss(val)
}
onCreateAny$ (): Observable<string> {
return this.$onCreate$.asObservable()
}
onDismissAny$ (): Observable<string> {
return this.$onDismiss$.asObservable()
}
async getTop (): Promise<HTMLIonModalElement> {
return this.modalCtrl.getTop()
}
get anyModals (): boolean {
return Object.keys(this.modals).length !== 0
}
get modalCount (): number {
return Object.keys(this.modals).length
}
}