Initial support for synchronizing url-imported plans based on the etag http header. This obviously requires the server hosting the plan files to correct enough etags

main
isark 1 year ago
parent 9b22ad942a
commit 54fd9ca62e

@ -23,6 +23,7 @@
"@types/markdown-it": "^13.0.0",
"@types/natural-compare": "^1.4.1",
"angular-resize-event": "^3.2.0",
"angular-svg-icon": "^17.0.0",
"bootstrap": "^5.3.2",
"fuzzr": "https://github.com/isark2/fuzzr#v0.3.1",
"markdown-it": "^13.0.1",

@ -20,7 +20,7 @@ ts-rs = "6.2.1"
[dependencies]
steamlocate = "1.2.1"
tauri = { version = "1.2", features = [ "http-request", "dialog-open", "global-shortcut-all", "dialog-save", "updater", "system-tray"] }
tauri = { version = "1.2", features = [ "dialog-message", "http-request", "dialog-open", "global-shortcut-all", "dialog-save", "updater", "system-tray"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Underlayer = { git = "https://git.isark.me/isark/Underlay.git" }

@ -103,8 +103,9 @@ fn save_plan_at_path(path: PathBuf, plan: Plan) -> bool {
}
#[tauri::command]
fn save_plan_at_store(name: String, plan: Plan) -> Option<PathBuf> {
Storage::save_plan_at_store_path(&name, plan).ok()
fn save_plan_at_store(name: String, plan: Plan, allow_overwrite: bool) -> Option<PathBuf> {
log::trace!("plan etag {:?}", plan.metadata.latest_server_etag);
Storage::save_plan_at_store_path(&name, plan, allow_overwrite).ok()
}
#[tauri::command]
@ -150,9 +151,9 @@ fn main() {
);
}
// app.get_window("Overlay")
// .expect("Could not get main overlay window")
// .open_devtools();
app.get_window("Overlay")
.expect("Could not get main overlay window")
.open_devtools();
Ok(())
})

@ -3,18 +3,19 @@ use std::{collections::HashMap, path::PathBuf};
use serde::{ser::SerializeStruct, Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct
Plan {
pub struct Plan {
plan: Vec<PlanElement>,
current: usize,
#[serde(flatten)]
metadata: PlanMetadata,
pub metadata: PlanMetadata,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PlanMetadata {
stored_path: Option<PathBuf>,
update_url: Option<String>,
pub latest_server_etag: Option<String>,
identifier: Option<String>,
}
impl PlanMetadata {
@ -26,19 +27,21 @@ impl PlanMetadata {
impl Serialize for PlanMetadata {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
S: serde::Serializer,
{
let mut state = serializer.serialize_struct("PlanMetadata", 3)?;
let mut state = serializer.serialize_struct("PlanMetadata", 5)?;
state.serialize_field("update_url", &self.update_url)?;
state.serialize_field("stored_path", &self.stored_path)?;
state.serialize_field("latest_server_etag", &self.latest_server_etag)?;
state.serialize_field("identifier", &self.identifier)?;
if let Some(path) = &self.stored_path {
if let Some(name) = path.file_name() {
state.serialize_field("name", &name.to_str())?;
}
}
state.end()
}
}
@ -128,6 +131,8 @@ pub fn convert_old(path: PathBuf) -> Option<Plan> {
metadata: PlanMetadata {
stored_path: None,
update_url: None,
latest_server_etag: None,
identifier: None,
},
})
}

@ -91,7 +91,7 @@ impl Storage {
};
if save_local {
match Self::save_plan_at_store_path(path.file_name()?.to_str()?, plan.clone()) {
match Self::save_plan_at_store_path(path.file_name()?.to_str()?, plan.clone(), false) {
Ok(_) => (),
Err(_e) => {
log::error!("Could not save plan at store path during load");
@ -102,7 +102,7 @@ impl Storage {
Some(plan)
}
pub fn save_plan_at_store_path(file_name: &str, mut plan: Plan) -> Result<PathBuf, Box<dyn Error>> {
pub fn save_plan_at_store_path(file_name: &str, mut plan: Plan, allow_overwrite: bool) -> Result<PathBuf, Box<dyn Error>> {
let plan_dir = match Self::plan_dir() {
Some(dir) => dir,
None => return Err("No plan dir".into()),
@ -111,10 +111,14 @@ impl Storage {
let file_path = plan_dir.join(file_name).with_extension("json");
//Disallow overwriting.
if file_path.exists() {
if !allow_overwrite && file_path.exists() {
return Err("File already exists".into());
}
if allow_overwrite && file_path.exists() {
log::info!("Overwriting plan : {file_path:?}");
}
//TODO: Determine necessity
plan.set_stored_path(Some(file_path.clone()));

@ -18,7 +18,8 @@
"allowlist": {
"dialog": {
"open": true,
"save": true
"save": true,
"message": true
},
"globalShortcut": {
"all": true

@ -7,12 +7,16 @@ export interface PlanInterface {
stored_path?: string;
update_url?: string;
name?: string;
latest_server_etag?: string;
identifier?: string,
}
export interface PlanMetadata {
stored_path?: string;
update_url?: string;
name: string;
latest_server_etag?: string;
identifier?: string;
}
export class Plan {
@ -20,16 +24,23 @@ export class Plan {
current: number;
update_url?: string;
name?: string;
latest_server_etag?: string;
identifier?: string;
private path?: string;
private selfSaveSubject: Subject<void> = new Subject<void>();
constructor(plan: PlanInterface) {
this.plan = plan.plan;
this.current = plan.current;
if (plan.stored_path) {
this.path = plan.stored_path;
}
this.update_url = plan.update_url;
this.name = plan.name;
this.latest_server_etag = plan.latest_server_etag;
this.identifier = plan.identifier;
this.selfSaveSubject.pipe(debounceTime(500)).subscribe(() => this.directSelfSave());
}
@ -57,6 +68,8 @@ export class Plan {
current: this.current,
stored_path: this.path,
update_url: this.update_url,
latest_server_etag: this.latest_server_etag,
identifier: this.identifier,
};
}

@ -32,12 +32,18 @@ export class OverlayService {
this.isOverlay = appWindow.label === "Overlay";
}
registerInitialBinds() {
this.shortcuts.register(this.configService.config.toggleOverlay).subscribe((_shortcut) => {
this.onToggleOverlay()
registerInitialBinds(retry: boolean = true) {
this.shortcuts.register(this.configService.config.toggleOverlay).subscribe({
next: (_shortcut) => {
this.onToggleOverlay()
},
error: (err) => {
console.error("error binding overlay toggle", err);
this.shortcuts.force_unbind(this.configService.config.toggleOverlay).subscribe(() => this.registerInitialBinds(false))
}
});
}
onOverlayStateChange(event: Event<StateEvent>) {
this.interactable = event.payload.Interactable != null;
if (event.payload.Hidden) { this.visible = false } else { this.visible = true };

@ -1,10 +1,20 @@
import { Injectable } from '@angular/core';
import { invoke } from '@tauri-apps/api';
import { Observable, ReplaySubject, Subject, from, map, switchMap, tap } from 'rxjs';
import { EMPTY, Observable, ReplaySubject, Subject, from, map, switchMap, tap } from 'rxjs';
import { Plan, PlanInterface, PlanMetadata } from '../_models/plan';
import { MatDialog } from '@angular/material/dialog';
import { UrlDialog } from '../plan-display/url-dialog.component';
import { fetch } from '@tauri-apps/api/http';
import { Response } from '@tauri-apps/api/http';
export class UrlError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
@Injectable({
providedIn: 'root'
@ -74,8 +84,33 @@ export class PlanService {
return from(invoke('save_plan_at_path', { path, plan: plan.toInterface() }));
}
public savePlanAtStore(name: string, plan: Plan) {
return from(invoke<string>('save_plan_at_store', { name, plan: plan.toInterface() })).pipe(tap(() => this.loadStoredPlans()));
public checkForPlanUpdate(plan: Plan | PlanMetadata): Observable<Plan> {
if (!plan.update_url) return EMPTY;
return from(fetch(
plan.update_url,
{
method: 'HEAD',
timeout: 10
})).pipe(switchMap(response => {
this.validateResponse(response);
if (response.headers['etag'] === plan.latest_server_etag) {
console.log("Same ETag, no update needed.")
return EMPTY;
}
console.log("Got new ETAG, updating plan.", response, plan);
return this._loadFromUrl(plan.update_url!, plan.name!, false);
}));
}
public savePlanAtStore(name: string, plan: Plan, allowOverwrite: boolean = false) {
console.log("save at store: ", plan);
return from(invoke<string>('save_plan_at_store', { name, plan: plan.toInterface(), allowOverwrite })).pipe(tap(() => {
console.log("saved plan at store pipe tap");
this.loadStoredPlans();
}));
}
private _loadFromUrl(url: string, name: string, save_local: boolean): Observable<Plan> {
@ -86,7 +121,16 @@ export class PlanService {
method: 'GET',
timeout: 10
})).pipe(map(response => {
return new Plan(response.data as PlanInterface);
this.validateResponse(response);
const plan = new Plan(response.data as PlanInterface);
const etag = response.headers['etag'];
if (etag) {
plan.latest_server_etag = etag;
console.log("got etag: ", etag);
}
return plan;
})).pipe(tap(plan => {
plan.update_url = url;
plan.name = name;
@ -97,6 +141,12 @@ export class PlanService {
}));
}
private validateResponse(response: Response<unknown>) {
if (!response.ok) {
throw new UrlError(response.status, "Error fetching plan from URL, status " + response.status + ", URL: " + response.url + " with body:" + response.data);
}
}
private loadBasePlan() {
from(invoke<PlanInterface>('base_plan')).subscribe(plan => {
plan.plan.forEach(elem => { elem.edited = false; });
@ -106,6 +156,7 @@ export class PlanService {
private loadStoredPlans() {
from(invoke<PlanMetadata[]>('enumerate_stored_plans')).subscribe(plans => {
console.log("updating stored plans");
this._storedPlansSubject.next(plans);
})
}

@ -1,6 +1,6 @@
import { Injectable, NgZone } from '@angular/core';
import { ShortcutHandler, register, unregister } from '@tauri-apps/api/globalShortcut';
import { Observable, Subscriber } from 'rxjs';
import { Observable, Subscriber, from } from 'rxjs';
@Injectable({
providedIn: 'root'
@ -8,7 +8,7 @@ import { Observable, Subscriber } from 'rxjs';
export class ShortcutService {
private internalHandlers: Map<string, [ShortcutHandler, Subscriber<string>, () => void]> = new Map<string, [ShortcutHandler, Subscriber<string>, () => void]>();
constructor(private zone: NgZone) {}
constructor(private zone: NgZone) { }
register(shortcut: string) {
return new Observable<string>((subscriber) => {
@ -21,12 +21,12 @@ export class ShortcutService {
};
this.internalHandlers.set(shortcut, [originalHandler, subscriber, teardown]);
register(shortcut, originalHandler)
register(shortcut, originalHandler).catch(e => subscriber.error(e));
return teardown;
});
}
rebind_from_to(previousShortcut: string, nextShortcut: string) {
let [oldHandler, subscriber, teardown] = this.internalHandlers.get(previousShortcut)!;
@ -43,4 +43,10 @@ export class ShortcutService {
this.internalHandlers.set(nextShortcut, [oldHandler, subscriber, teardown]);
}
///No safety checks
force_unbind(shortcut: string) {
return from(unregister(shortcut));
}
}

@ -16,6 +16,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { MAT_DIALOG_DEFAULT_OPTIONS } from "@angular/material/dialog";
import { TooltipComponent } from "./tooltip/tooltip.component";
import { HttpClientModule } from "@angular/common/http";
import { AngularSvgIconModule } from "angular-svg-icon";
// import { GemFinderComponent } from "./gem-finder/gem-finder.component";
export function initializeApp(configService: ConfigService) {
@ -40,7 +41,8 @@ export function initializeApp(configService: ConfigService) {
SettingsComponent,
MatTabsModule,
TooltipComponent,
HttpClientModule
HttpClientModule,
AngularSvgIconModule.forRoot(),
],
providers: [
{

@ -6,9 +6,8 @@
[style.transform]="transform()" [style.width]="rect.width + 'px'" [style.height]="rect.height + 'px'"
[class]="specialClasses()" (wheel)="onScroll($event)" #targetRef>
<ng-container *ngIf="currentPlan">
<carousel class="zones" [initIndex]="currentPlan.current"
[numVisible]="configService.config.numVisible" [offset]="clampedOffset()"
[slides]="currentPlan.plan" (afterInitSelf)="registerZoneSlides($event)"
<carousel class="zones" [initIndex]="currentPlan.current" [numVisible]="configService.config.numVisible"
[offset]="clampedOffset()" [slides]="currentPlan.plan" (afterInitSelf)="registerZoneSlides($event)"
[ngStyle]="zonesStyle()">
<ng-template let-slide let-index="index">
<div class="zone-slide" [style.color]="configService.config.noteDefaultFg"
@ -60,8 +59,8 @@
[cdkConnectedOverlayOpen]="(settingsOpen || !currentPlan) && overlayService.interactable"
[cdkConnectedOverlayOrigin]="globalTopLeft" (detach)="settingsOpen = false">
<div class="overlay container-fluid vw-100">
<div class="row row-cols-2">
<div class="planChooser col-xs-6 col-sm-6 col-md-6 col-lg-4 col-xl-4">
<div class="row row-cols-2 h-100">
<div class="planChooser col-xs-6 col-sm-6 col-md-6 col-lg-4 col-xl-4 d-flex flex-column">
<div class="d-flex justify-content-evenly">
<div class="col-xs-4">
<button class="" mat-raised-button color="accent" (click)="openDialog()">Browse
@ -79,16 +78,29 @@
</button>
</div>
</div>
<div class="enumerated">
<mat-list role="list">
<mat-list-item class="d-flex flex-column" role="listitem"
*ngFor="let plan of previousPlans">
<button (click)="loadPrevious(plan.stored_path!)">
<img *ngIf="plan.update_url" src="assets/public.svg">{{plan.name}}
</button>
</mat-list-item>
<div class="enumerated d-flex flex-column">
<mat-list role="list" class="d-flex flex-column h-100">
<cdk-virtual-scroll-viewport itemSize="10" class="h-100">
<mat-list-item class="d-flex flex-column" role="listitem"
*cdkVirtualFor="let plan of previousPlans">
<span>
<button [disabled]="disablePlans" (click)="loadPrevious(plan.stored_path!)">
<img *ngIf="plan.update_url" src="assets/public.svg">{{plan.name}}
</button><button *ngIf="plan.update_url" class="planActionButton"
[disabled]="disablePlans" (click)="checkForPlanUpdate(plan)"
[title]="recentPlanTitle(plan)">
<svg-icon *ngIf="!hasUpdate(plan)" src="assets/material-sync.svg" />
<svg-icon *ngIf="hasUpdate(plan) && !isErr(plan)"
src="assets/material-check.svg" class="nice" />
<svg-icon *ngIf="hasUpdate(plan) && isErr(plan)"
src="assets/material-warning.svg" class="notnice" />
</button>
</span>
</mat-list-item>
</cdk-virtual-scroll-viewport>
</mat-list>
</div>
</div>
<settings class="col-xs-6 col-sm-6 offset-md-1 col-md-5 offset-lg-4 col-lg-4 offset-xl-4 col-xl-4">

@ -167,9 +167,28 @@ notes {
.enumerated {
display: block;
overflow: hidden;
flex-grow: 1;
mat-list {
max-height: 100%;
overflow-y: scroll;
}
}
.planActionButton {
align-items: center;
justify-content: center;
padding-bottom: 1px;
}
.nice {
svg {
fill: green;
}
}
.notnice {
svg {
fill: red;
}
}

@ -5,7 +5,7 @@ import { ConfigService } from '../_services/config.service';
import { Rect } from '../_models/generated/Rect';
import { ShortcutService } from '../_services/shortcut.service';
import { CarouselComponent } from '../carousel/carousel.component';
import { PlanService } from '../_services/plan.service';
import { PlanService, UrlError } from '../_services/plan.service';
import { Plan, PlanElement, PlanMetadata } from '../_models/plan';
import { WorldAreaService } from '../_services/world-area.service';
import { WorldArea } from '../_models/world-area';
@ -22,7 +22,6 @@ import { Event } from '@tauri-apps/api/event';
styleUrls: ['./plan-display.component.scss']
})
export class PlanDisplayComponent implements AfterViewInit, OnInit {
@Input() backgroundColor?: String;
draggable: boolean = true;
rect?: Rect;
@ -44,6 +43,9 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit {
currentPlan?: Plan;
previousPlans: PlanMetadata[] = [];
checkingPlanUpdate: boolean = false;
recentUpdateAttempts: Map<string, Date | UrlError> = new Map<string, Date | UrlError>();
constructor(private events: EventsService, public configService: ConfigService, private cdr: ChangeDetectorRef, private shortcut: ShortcutService, public planService: PlanService, public worldAreaService: WorldAreaService, public overlayService: OverlayService, private zone: NgZone) {
window.addEventListener("resize", () => {
@ -53,6 +55,7 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit {
});
this.planService.getStoredPlans().subscribe(plans => {
console.log("got new stored plans");
this.previousPlans = plans;
})
@ -66,6 +69,10 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit {
this.registerOnZoneEnter();
}
get disablePlans(): boolean {
return this.checkingPlanUpdate;
}
registerOnZoneEnter() {
appWindow.listen("entered", (entered) => {
if (this.currentPlan) {
@ -80,7 +87,6 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit {
});
}
windowInitHandler() {
if (window.innerWidth > 0) {
this.ngAfterViewInit();
@ -304,7 +310,7 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit {
this.planService.loadFromUrl().subscribe(plan => {
if (plan) {
this.planService.savePlanAtStore(plan.name!, plan).subscribe((path) => {
if(path) {
if (path) {
plan.setPath(path);
}
});
@ -312,4 +318,66 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit {
}
});
}
checkForPlanUpdate(plan: PlanMetadata) {
this.checkingPlanUpdate = true;
this.planService.checkForPlanUpdate(plan).subscribe({
next: updatedPlan => {
console.log("check update: ", updatedPlan);
this.planService.loadPlanFromPath(plan.stored_path!, false).subscribe(oldPlan => {
console.log("oldPlan", oldPlan);
const newCurrent = commonUUIDToNewCurrent(oldPlan, updatedPlan);
updatedPlan.current = newCurrent;
//TODO: Interface to determine if we want to save the plan or not (allow not doing it in case user regrets... Also needs position error fix).
this.planService.savePlanAtStore(oldPlan.name!, updatedPlan, true).subscribe();
})
},
complete: () => {
this.checkingPlanUpdate = false
this.recentUpdateAttempts.set(plan.update_url!, new Date());
},
error: (err) => {
this.recentUpdateAttempts.set(plan.update_url!, err);
if (err instanceof UrlError) {
alert("Error retrieving plan update (" + err.status + "): " + err.message);
} else if (err instanceof Error) {
alert("Unexpected error: " + err.message);
}
this.checkingPlanUpdate = false;
}
});
}
isErr(plan: PlanMetadata) {
return this.recentUpdateAttempts.get(plan.update_url!) instanceof UrlError;
}
hasUpdate(plan: PlanMetadata): any {
return this.recentUpdateAttempts.has(plan.update_url!);
}
recentPlanTitle(plan: PlanMetadata) {
if (!this.hasUpdate(plan)) return "Check for update";
if (this.hasUpdate(plan) && !this.isErr(plan)) return "Up to date";
if (this.hasUpdate(plan) && this.isErr(plan)) return "Error updating";
return "";
}
}
function commonUUIDToNewCurrent(oldPlan: Plan, updatedPlan: Plan): number {
let bestOldCurrent = oldPlan.current;
let newIndex = 0;
//Attempt current plan element's UUID first, otherwise step backwards until we find a match
while (bestOldCurrent >= 0) {
const tempNewIndex = updatedPlan.plan.findIndex(e => e.uuid === oldPlan.plan[bestOldCurrent].uuid);
if (tempNewIndex !== -1) {
newIndex = tempNewIndex;
break;
}
bestOldCurrent--;
}
return newIndex;
}

@ -14,6 +14,8 @@ import { MatListModule } from '@angular/material/list';
import { ScalableComponent } from '../Scalable/scalable.component';
import {MatTooltipModule} from '@angular/material/tooltip';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { AngularSvgIconModule } from 'angular-svg-icon';
import { ScrollingModule } from '@angular/cdk/scrolling';
@NgModule({
declarations: [
PlanDisplayComponent
@ -33,7 +35,9 @@ import { TooltipComponent } from '../tooltip/tooltip.component';
MatListModule,
ScalableComponent,
MatTooltipModule,
TooltipComponent
TooltipComponent,
AngularSvgIconModule,
ScrollingModule
],
exports: [
PlanDisplayComponent

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M382-240 154-468l57-57 171 171 367-367 57 57-424 424Z"/></svg>

After

Width:  |  Height:  |  Size: 159 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M160-160v-80h110l-16-14q-52-46-73-105t-21-119q0-111 66.5-197.5T400-790v84q-72 26-116 88.5T240-478q0 45 17 87.5t53 78.5l10 10v-98h80v240H160Zm400-10v-84q72-26 116-88.5T720-482q0-45-17-87.5T650-648l-10-10v98h-80v-240h240v80H690l16 14q49 49 71.5 106.5T800-482q0 111-66.5 197.5T560-170Z"/></svg>

After

Width:  |  Height:  |  Size: 388 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m40-120 440-760 440 760H40Zm138-80h604L480-720 178-200Zm302-40q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Zm40-100Z"/></svg>

After

Width:  |  Height:  |  Size: 295 B

@ -2883,6 +2883,13 @@ angular-resize-event@^3.2.0:
dependencies:
tslib "^2.3.0"
angular-svg-icon@^17.0.0:
version "17.0.0"
resolved "https://registry.yarnpkg.com/angular-svg-icon/-/angular-svg-icon-17.0.0.tgz#656e54a6ef1dc8322b5cde649798fb5ce2349b4a"
integrity sha512-cTHz79nzVcfUSBPwclFRwvAsWYbo3rRTse45lnzmHdCV0GjTLUNE3C1yQwBAnHqlH4eN0sl+mbVpZh8YeJxYug==
dependencies:
tslib "^2.3.1"
ansi-colors@4.1.3:
version "4.1.3"
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b"
@ -6877,6 +6884,11 @@ tslib@^2.1.0, tslib@^2.3.0, tslib@^2.6.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.0.tgz#b295854684dbda164e181d259a22cd779dcd7bc3"
integrity sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==
tslib@^2.3.1:
version "2.6.2"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
tuf-js@^1.1.7:
version "1.1.7"
resolved "https://registry.yarnpkg.com/tuf-js/-/tuf-js-1.1.7.tgz#21b7ae92a9373015be77dfe0cb282a80ec3bbe43"

Loading…
Cancel
Save