diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 755d93a3..8510f17e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -46,7 +46,7 @@ jobs: run: npm ci - name: Run linting - run: npm run lint:js -- -- --max-warnings 0 + run: npm run lint:js -- --max-warnings 0 build-test: name: Build & test 🛠️ diff --git a/README.md b/README.md index 5bff43fa..80824e4e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,14 @@ Here's an overview on the structure of this repository, which is designed to be ``` - Note that if you already have a `VCockpit` with `NO_TEXTURE` you can just add another `htmlgauge` to it, while making sure to increase the index +## Dealing with Bundled Navigation Data + +If you bundle outdated navigation data in your aircraft and you want this module to handle updating it for users with subscriptions, place the navigation data into the `NavigationData` directory in `PackageSources`. You can see an example [here](examples/aircraft/PackageSources/NavigationData/) + +## Where is the Navigation Data Stored? + +The default location for navigation data is `work/NavigationData`. If you have bundled navigation data, its located in the `NavigationData` folder in the root of your project. (although it gets copied into the `work` directory at runtime) + ## Building the Sample Aircraft Before building, make sure you have properly created and set an `.env` file in `examples/gauge`! An example can be found in the `.env.example` file in that directory. Replace with your credentials diff --git a/examples/aircraft/NavigationDataInterfaceAircraftProject.xml b/examples/aircraft/NavigationDataInterfaceAircraftProject.xml index 9e2771a7..d80ada3f 100644 --- a/examples/aircraft/NavigationDataInterfaceAircraftProject.xml +++ b/examples/aircraft/NavigationDataInterfaceAircraftProject.xml @@ -1,11 +1,11 @@ - - - . - _PackageInt - _PublishingGroupInt - - PackageDefinitions\navigraph-aircraft-navigation-data-interface-sample.xml - - - - + + + . + _PackageInt + _PublishingGroupInt + + PackageDefinitions\navigraph-aircraft-navigation-data-interface-sample.xml + + + + diff --git a/examples/aircraft/PackageDefinitions/navigraph-aircraft-navigation-data-interface-sample.xml b/examples/aircraft/PackageDefinitions/navigraph-aircraft-navigation-data-interface-sample.xml index 3277cd84..df4e18f5 100644 --- a/examples/aircraft/PackageDefinitions/navigraph-aircraft-navigation-data-interface-sample.xml +++ b/examples/aircraft/PackageDefinitions/navigraph-aircraft-navigation-data-interface-sample.xml @@ -1,48 +1,56 @@ - - - - AIRCRAFT - Navigraph Navigation Data Interface Sample Aircraft - My Manufacturer - Navigraph - - - true - true - - - - Copy - - false - - PackageDefinitions\navigraph-aircraft-navigation-data-interface-sample\ContentInfo\ - ContentInfo\navigraph-aircraft-navigation-data-interface-sample\ - - - Copy - - false - - PackageSources\Data\ - Data\ - - - SimObject - - false - - PackageSources\SimObjects\Airplanes\Navigraph_Navigation_Data_Interface_Aircraft\ - SimObjects\Airplanes\Navigraph_Navigation_Data_Interface_Aircraft\ - - - Copy - - false - - PackageSources\html_ui\ - html_ui\ - - - - + + + + AIRCRAFT + Navigraph Navigation Data Interface Sample Aircraft + My Manufacturer + Navigraph + + + true + true + + + + Copy + + false + + PackageDefinitions\navigraph-aircraft-navigation-data-interface-sample\ContentInfo\ + ContentInfo\navigraph-aircraft-navigation-data-interface-sample\ + + + Copy + + false + + PackageSources\Data\ + Data\ + + + Copy + + false + + PackageSources\NavigationData\ + NavigationData\ + + + SimObject + + false + + PackageSources\SimObjects\Airplanes\Navigraph_Navigation_Data_Interface_Aircraft\ + SimObjects\Airplanes\Navigraph_Navigation_Data_Interface_Aircraft\ + + + Copy + + false + + PackageSources\html_ui\ + html_ui\ + + + + diff --git a/examples/aircraft/PackageSources/NavigationData/cycle.json b/examples/aircraft/PackageSources/NavigationData/cycle.json new file mode 100644 index 00000000..55cd1062 --- /dev/null +++ b/examples/aircraft/PackageSources/NavigationData/cycle.json @@ -0,0 +1 @@ +{"cycle":"2101","revision":"1","name":"Navigraph Avionics", "format": "dfd", "validityPeriod": "2021-01-25/2021-02-20"} \ No newline at end of file diff --git a/examples/aircraft/PackageSources/NavigationData/e_dfd_2101.s3db b/examples/aircraft/PackageSources/NavigationData/e_dfd_2101.s3db new file mode 100644 index 00000000..d06d0826 Binary files /dev/null and b/examples/aircraft/PackageSources/NavigationData/e_dfd_2101.s3db differ diff --git a/examples/gauge/Components/InterfaceSample.tsx b/examples/gauge/Components/InterfaceSample.tsx index 5cfa6e84..67de4a83 100644 --- a/examples/gauge/Components/InterfaceSample.tsx +++ b/examples/gauge/Components/InterfaceSample.tsx @@ -22,7 +22,6 @@ export class InterfaceSample extends DisplayComponent { private readonly dropdownRef = FSComponent.createRef() private readonly downloadButtonRef = FSComponent.createRef() private readonly executeButtonRef = FSComponent.createRef() - private readonly setActiveButtonRef = FSComponent.createRef() private readonly inputRef = FSComponent.createRef() private cancelSource = CancelToken.source() @@ -69,9 +68,6 @@ export class InterfaceSample extends DisplayComponent {
Download
-
- Set as Active -
Execute SQL @@ -103,16 +99,6 @@ export class InterfaceSample extends DisplayComponent { .catch(e => console.error(e)) }) - this.setActiveButtonRef.instance.addEventListener("click", () => { - const format = this.dropdownRef.instance.getNavigationDataFormat() - if (!format) return - // This will only work if the database specified is a SQLite database - this.navigationDataInterface - .set_active_database(format) - .then(() => console.info("WASM set active database")) - .catch(err => this.displayError(String(err))) - }) - AuthService.user.sub(user => { if (user) { this.qrCodeRef.instance.src = "" @@ -173,7 +159,7 @@ export class InterfaceSample extends DisplayComponent { const pkg = await packages.getPackage(format) // Download navigation data to work dir - await this.navigationDataInterface.download_navigation_data(pkg.file.url, pkg.format) + await this.navigationDataInterface.download_navigation_data(pkg.file.url) this.displayMessage("Navigation data downloaded") } catch (err) { if (err instanceof Error) this.displayError(err.message) diff --git a/package-lock.json b/package-lock.json index 52c05fa1..fc1a28eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,13 +16,13 @@ "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.9.0", "@typescript-eslint/parser": "^6.9.0", + "bigint-buffer": "^1.1.5", "dotenv": "^16.3.1", "eslint": "^8.52.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.1", "jest": "^29.7.0", "prettier": "^3.0.3", - "random-bigint": "^0.0.1", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", "tsup": "^8.0.1", @@ -3094,6 +3094,19 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bigint-buffer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/bigint-buffer/-/bigint-buffer-1.1.5.tgz", + "integrity": "sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.3.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -3103,6 +3116,15 @@ "node": ">=8" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -4215,6 +4237,12 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -7165,15 +7193,6 @@ } ] }, - "node_modules/random-bigint": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/random-bigint/-/random-bigint-0.0.1.tgz", - "integrity": "sha512-X+NTsf5Hzl/tRNLiNTD3N1LRU0eKdIE0+plNlV1CmXLTlnAxj6HipcTnOhWvFRoSytCz6l1f4KYFf/iH8NNSLw==", - "dev": true, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", diff --git a/package.json b/package.json index a203bb0e..166002c2 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,10 @@ "eslint-plugin-prettier": "^5.0.1", "jest": "^29.7.0", "prettier": "^3.0.3", - "random-bigint": "^0.0.1", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", "tsup": "^8.0.1", "typescript": "^5.2.2", "uuid": "^9.0.1" } -} +} \ No newline at end of file diff --git a/src/database/src/database.rs b/src/database/src/database.rs index cb216115..fd29a511 100644 --- a/src/database/src/database.rs +++ b/src/database/src/database.rs @@ -27,35 +27,53 @@ use crate::{ vhf_navaid::VhfNavaid, waypoint::Waypoint, }, - sql_structs::{self}, - util, + sql_structs, util, }; pub struct Database { database: Option, + pub path: Option, } #[derive(Debug)] struct NoDatabaseOpen; impl Display for NoDatabaseOpen { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "No database open") } + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "No database open") + } } impl Error for NoDatabaseOpen {} impl Database { - pub fn new() -> Self { Database { database: None } } + pub fn new() -> Self { + Database { + database: None, + path: None, + } + } - fn get_database(&self) -> Result<&Connection, NoDatabaseOpen> { self.database.as_ref().ok_or(NoDatabaseOpen) } + fn get_database(&self) -> Result<&Connection, NoDatabaseOpen> { + self.database.as_ref().ok_or(NoDatabaseOpen) + } - pub fn set_active_database(&mut self, mut path: String) -> Result<(), Box> { - // Check if the path is a directory and if it is, search for a sqlite file - let formatted_path = format!("\\work/{}", path); - if util::get_path_type(std::path::Path::new(&formatted_path)) == util::PathType::Directory { - path = util::find_sqlite_file(&formatted_path)?; + pub fn set_active_database(&mut self, path: String) -> Result<(), Box> { + let path = match util::find_sqlite_file(&path) { + Ok(new_path) => new_path, + Err(_) => path, + }; + println!("[NAVIGRAPH] Setting active database to {}", path); + self.close_connection(); + if util::is_sqlite_file(&path)? { + self.open_connection(path.clone())?; } + self.path = Some(path); + Ok(()) + } + + pub fn open_connection(&mut self, path: String) -> Result<(), Box> { // We have to open with flags because the SQLITE_OPEN_CREATE flag with the default open causes the file to // be overwritten let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX; @@ -541,5 +559,7 @@ impl Database { Ok(data) } - pub fn close_connection(&mut self) { self.database = None; } + pub fn close_connection(&mut self) { + self.database = None; + } } diff --git a/src/database/src/math.rs b/src/database/src/math.rs index ff1ce77a..24a98847 100644 --- a/src/database/src/math.rs +++ b/src/database/src/math.rs @@ -10,7 +10,9 @@ pub type Minutes = f64; pub type KiloHertz = f64; pub type MegaHertz = f64; -pub(crate) fn feet_to_meters(metres: Meters) -> Feet { metres / 3.28084 } +pub(crate) fn feet_to_meters(metres: Meters) -> Feet { + metres / 3.28084 +} #[derive(Serialize, Deserialize, Debug, Copy, Clone)] pub struct Coordinates { diff --git a/src/database/src/sql_structs.rs b/src/database/src/sql_structs.rs index 67c3a285..a66fbf71 100644 --- a/src/database/src/sql_structs.rs +++ b/src/database/src/sql_structs.rs @@ -1,22 +1,11 @@ use serde::Deserialize; use super::enums::{ - AirwayDirection, - AirwayLevel, - AirwayRouteType, - AltitudeDescriptor, - LegType, - SpeedDescriptor, - TurnDirection, + AirwayDirection, AirwayLevel, AirwayRouteType, AltitudeDescriptor, LegType, SpeedDescriptor, TurnDirection, }; use crate::enums::{ - ApproachTypeIdentifier, - CommunicationType, - ControlledAirspaceType, - FrequencyUnits, - IfrCapability, - RestrictiveAirspaceType, - RunwaySurfaceCode, + ApproachTypeIdentifier, CommunicationType, ControlledAirspaceType, FrequencyUnits, IfrCapability, + RestrictiveAirspaceType, RunwaySurfaceCode, }; #[derive(Deserialize, Debug)] diff --git a/src/database/src/util.rs b/src/database/src/util.rs index ac9c06c5..7f387eb7 100644 --- a/src/database/src/util.rs +++ b/src/database/src/util.rs @@ -1,4 +1,9 @@ -use std::{fs, io::Read, path::Path}; +use std::{error::Error, fs, io::Read, path::Path}; + +// From 1.3.1 of https://www.sqlite.org/fileformat.html +const SQLITE_HEADER: [u8; 16] = [ + 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00, +]; #[derive(PartialEq, Eq)] pub enum PathType { @@ -31,27 +36,33 @@ pub fn get_path_type(path: &Path) -> PathType { PathType::DoesNotExist } -pub fn find_sqlite_file(path: &str) -> Result> { - // From 1.3.1 of https://www.sqlite.org/fileformat.html - let sqlite_header = [ - 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00, - ]; +pub fn find_sqlite_file(path: &str) -> Result> { + if get_path_type(&Path::new(path)) != PathType::Directory { + return Err("Path is not a directory".into()); + } + // We are going to search this directory for a database for entry in std::fs::read_dir(path)? { let entry = entry?; let path = entry.path(); if get_path_type(&path) == PathType::File { let path = path.to_str().ok_or("Invalid path")?; - // Get first 16 bytes of file - let mut file = std::fs::File::open(path)?; - let mut buf = [0; 16]; - file.read_exact(buf.as_mut())?; - // Compare bytes to sqlite header - if buf == sqlite_header { - // We found a database + + if is_sqlite_file(path)? { return Ok(path.to_string()); } } } Err("No SQL database found. Make sure the database specified is a SQL database".into()) } + +pub fn is_sqlite_file(path: &str) -> Result> { + if get_path_type(&Path::new(path)) != PathType::File { + return Ok(false); + } + + let mut file = fs::File::open(path)?; + let mut buf = [0; 16]; + file.read_exact(&mut buf)?; + Ok(buf == SQLITE_HEADER) +} diff --git a/src/js/interface/NavigationDataInterfaceTypes.ts b/src/js/interface/NavigationDataInterfaceTypes.ts index df9b1bf1..5945cb61 100644 --- a/src/js/interface/NavigationDataInterfaceTypes.ts +++ b/src/js/interface/NavigationDataInterfaceTypes.ts @@ -25,7 +25,7 @@ export interface DownloadProgressData { export enum NavigraphFunction { DownloadNavigationData = "DownloadNavigationData", SetDownloadOptions = "SetDownloadOptions", - SetActiveDatabase = "SetActiveDatabase", + GetNavigationDataInstallStatus = "GetNavigationDataInstallStatus", ExecuteSQLQuery = "ExecuteSQLQuery", GetDatabaseInfo = "GetDatabaseInfo", GetAirport = "GetAirport", diff --git a/src/js/interface/NavigraphNavigationDataInterface.ts b/src/js/interface/NavigraphNavigationDataInterface.ts index e7027a01..588852bc 100644 --- a/src/js/interface/NavigraphNavigationDataInterface.ts +++ b/src/js/interface/NavigraphNavigationDataInterface.ts @@ -18,6 +18,7 @@ import { VhfNavaid, Waypoint, } from "../types" +import { NavigationDataStatus } from "../types/meta" import { Callback, CommBusMessage, @@ -69,11 +70,10 @@ export class NavigraphNavigationDataInterface { * Downloads the navigation data from the given URL to the given path * * @param url - A valid signed URL to download the navigation data from - * @param path - The path to download the navigation data to * @returns A promise that resolves when the download is complete */ - public async download_navigation_data(url: string, path: string): Promise { - return await this.callWasmFunction("DownloadNavigationData", { url, path }) + public async download_navigation_data(url: string): Promise { + return await this.callWasmFunction("DownloadNavigationData", { url }) } /** @@ -87,16 +87,12 @@ export class NavigraphNavigationDataInterface { } /** - * Sets the active DFD database to the one at the given path + * Gets the installation status of the navigation data * - * @remarks - * The path must be a valid path to a folder that contains a DFD file. - * - * @param path - The path to the folder that contains the DFD file - * @returns A promise that resolves when the function is complete + * @returns A promise that resolves with the installation status */ - public async set_active_database(path: string): Promise { - return await this.callWasmFunction("SetActiveDatabase", { path }) + public async get_navigation_data_install_status(): Promise { + return await this.callWasmFunction("GetNavigationDataInstallStatus", {}) } /** diff --git a/src/js/types/meta.ts b/src/js/types/meta.ts new file mode 100644 index 00000000..86fc0c28 --- /dev/null +++ b/src/js/types/meta.ts @@ -0,0 +1,15 @@ +export enum InstallStatus { + Bundled = "Bundled", + Manual = "Manual", + None = "None", +} + +export interface NavigationDataStatus { + status: InstallStatus + installedFormat: string | null + installedRegion: string | null + installedCycle: string | null + installedPath: string | null + validityPeriod: string | null + lastestCycle: string | null +} diff --git a/src/test/RandomBigint.d.ts b/src/test/RandomBigint.d.ts deleted file mode 100644 index 8ea90983..00000000 --- a/src/test/RandomBigint.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module "random-bigint" { - export default function random(size: number): bigint -} diff --git a/src/test/randomBigint.ts b/src/test/randomBigint.ts new file mode 100644 index 00000000..460bdddc --- /dev/null +++ b/src/test/randomBigint.ts @@ -0,0 +1,51 @@ +// https://github.com/bnoordhuis/random-bigint/blob/master/index.js + +import { randomBytes } from "crypto" + +export function random(bits: number) { + if (bits < 0) + throw new RangeError('bits < 0') + + // @ts-ignore + const n = (bits >>> 3) + !!(bits & 7) // Round up to next byte. + const r = 8*n - bits + const s = 8 - r + const m = (1 << s) - 1 // Bits to mask off from MSB. + + const bytes = randomBytes(n) + + maskbits(m, bytes) + + return bytes2bigint(bytes) +} + +function maskbits(m: number, bytes: Buffer) { + // Mask off bits from the MSB that are > log2(bits). + // |bytes| is treated as a big-endian bigint so byte 0 is the MSB. + if (bytes.length > 0) + bytes[0] &= m +} + +function bytes2bigint(bytes: Buffer) { + let result = BigInt(0) + + const n = bytes.length + + // Read input in 8 byte slices. This is, on average and at the time + // of writing, about 35x faster for large inputs than processing them + // one byte at a time. + if (n >= 8) { + const view = new DataView(bytes.buffer, bytes.byteOffset) + + for (let i = 0, k = n & ~7; i < k; i += 8) { + const x = view.getBigUint64(i, false) + result = (result << BigInt(64)) + x + } + } + + // Now mop up any remaining bytes. + for (let i = n & ~7; i < n; i++) + result = result * BigInt(256) + BigInt(bytes[i]) + + return result +} \ No newline at end of file diff --git a/src/test/setup.ts b/src/test/setup.ts index 06eace43..5e71977d 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,11 +1,11 @@ import { readFileSync } from "node:fs" import { argv, env } from "node:process" -import random from "random-bigint" -import { v4 } from "uuid" import { WASI } from "wasi" +import { v4 } from "uuid" import { NavigraphNavigationDataInterface } from "../js" import { WEBASSEMBLY_PATH, WORK_FOLDER_PATH } from "./constants" import "dotenv/config" +import { random } from "./randomBigint" enum PanelService { POST_QUERY = 1, @@ -157,6 +157,7 @@ let wasmFunctionTable: WebAssembly.Table // The table of callback functions in t * Maps request ids to a tuple of the returned data's pointer, and the data's size */ const promiseResults = new Map() +const failedRequests: bigint[] = [] wasmInstance = new WebAssembly.Instance(wasmModule, { wasi_snapshot_preview1: wasiSystem.wasiImport, @@ -205,7 +206,7 @@ wasmInstance = new WebAssembly.Instance(wasmModule, { fsNetworkHttpRequestGet: (urlPointer: number, paramPointer: number, callback: number, ctx: number) => { const url = readString(urlPointer) - const requestId: bigint = random(32) // Setting it to 64 does... strange things + const requestId = random(16) // Extra bits get lopped off by WASM, this number works // Currently the only network request is for the navigation data zip which is downloaded as a blob fetch(url) @@ -222,11 +223,20 @@ wasmInstance = new WebAssembly.Instance(wasmModule, { func(requestId, 200, ctx) }) .catch(err => { - console.error(err) + failedRequests.push(requestId) }) return requestId }, + fsNetworkHttpRequestGetState: (requestId: bigint) => { + if (failedRequests.includes(requestId)) { + return 4 // FS_NETWORK_HTTP_REQUEST_STATE_FAILED + } + if (promiseResults.has(requestId)) { + return 3 // FS_NETWORK_HTTP_REQUEST_STATE_DATA_READY + } + return 2 // FS_NETWORK_HTTP_REQUEST_STATE_WAITING_FOR_DATA + }, }, }) as WasmInstance @@ -280,11 +290,16 @@ beforeAll(async () => { throw new Error("Please specify the env var `NAVIGATION_DATA_SIGNED_URL`") } - // Download navigation data to a unique folder to prevent clashes - const path = v4() + // Utility function to convert onReady to a promise + const waitForReady = (navDataInterface: NavigraphNavigationDataInterface): Promise => { + return new Promise((resolve, _reject) => { + navDataInterface.onReady(() => resolve()) + }) + } + + await waitForReady(navigationDataInterface) - await navigationDataInterface.download_navigation_data(downloadUrl, path) - await navigationDataInterface.set_active_database(path) + await navigationDataInterface.download_navigation_data(downloadUrl) }, 30000) void lifeCycle() diff --git a/src/wasm/src/consts.rs b/src/wasm/src/consts.rs new file mode 100644 index 00000000..d43536a2 --- /dev/null +++ b/src/wasm/src/consts.rs @@ -0,0 +1,3 @@ +pub const NAVIGATION_DATA_DEFAULT_LOCATION: &str = ".\\NavigationData"; +pub const NAVIGATION_DATA_WORK_LOCATION: &str = "\\work/NavigationData"; +pub const NAVIGATION_DATA_INTERNAL_CONFIG_LOCATION: &str = "\\work/navigraph_config.json"; diff --git a/src/wasm/src/dispatcher.rs b/src/wasm/src/dispatcher.rs index a1c64ad3..88edd976 100644 --- a/src/wasm/src/dispatcher.rs +++ b/src/wasm/src/dispatcher.rs @@ -1,16 +1,19 @@ -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, path::Path, rc::Rc}; -use msfs::{commbus::*, sys::sGaugeDrawData, MSFSEvent}; +use msfs::{commbus::*, network::NetworkRequestState, sys::sGaugeDrawData, MSFSEvent}; use navigation_database::database::Database; use crate::{ - download::downloader::NavigationDataDownloader, + consts, + download::downloader::{DownloadStatus, NavigationDataDownloader}, json_structs::{ events, functions::{CallFunction, FunctionResult, FunctionStatus, FunctionType}, params, }, - util, + meta::{self, InternalState}, + network_helper::NetworkHelper, + util::{self, path_exists}, }; #[derive(PartialEq, Eq)] @@ -26,6 +29,7 @@ pub struct Task { pub id: String, pub data: Option, pub status: TaskStatus, + pub associated_network_request: Option, } impl Task { @@ -76,20 +80,19 @@ impl<'a> Dispatcher<'a> { } fn handle_initialized(&mut self) { - { - // We need to clone twice because we need to move the queue into the closure and then clone it again - // whenever it gets called - let captured_queue = Rc::clone(&self.queue); - self.commbus - .register("NAVIGRAPH_CallFunction", move |args| { - // TODO: maybe send error back to sim? - match Dispatcher::add_to_queue(Rc::clone(&captured_queue), args) { - Ok(_) => (), - Err(e) => println!("[NAVIGRAPH] Failed to add to queue: {}", e), - } - }) - .expect("Failed to register NAVIGRAPH_CallFunction"); - } + self.load_database(); + // We need to clone twice because we need to move the queue into the closure and then clone it again + // whenever it gets called + let captured_queue = Rc::clone(&self.queue); + self.commbus + .register("NAVIGRAPH_CallFunction", move |args| { + // TODO: maybe send error back to sim? + match Dispatcher::add_to_queue(Rc::clone(&captured_queue), args) { + Ok(_) => (), + Err(e) => println!("[NAVIGRAPH] Failed to add to queue: {}", e), + } + }) + .expect("Failed to register NAVIGRAPH_CallFunction"); } fn handle_update(&mut self, data: &sGaugeDrawData) { @@ -104,6 +107,103 @@ impl<'a> Dispatcher<'a> { self.process_queue(); self.downloader.on_update(); + + // Because the download process doesn't finish in the function call, we need to check if the download is finished to call the on_download_finish function + if *self.downloader.download_status.borrow() == DownloadStatus::Downloaded { + self.on_download_finish(); + self.downloader.acknowledge_download(); + } + } + fn load_database(&mut self) { + println!("[NAVIGRAPH] Loading database"); + + // Go through logic to determine which database to load + + // Are we bundled? None means we haven't installed anything yet + let is_bundled = meta::get_internal_state() + .map(|internal_state| Some(internal_state.is_bundled)) + .unwrap_or(None); + + // Get the installed cycle (if it exists) + let installed_cycle = match meta::get_installed_cycle_from_json( + &Path::new(consts::NAVIGATION_DATA_WORK_LOCATION).join("cycle.json"), + ) { + Ok(cycle) => Some(cycle.cycle), + Err(_) => None, + }; + + // Get the bundled cycle (if it exists) + let bundled_cycle = match meta::get_installed_cycle_from_json( + &Path::new(consts::NAVIGATION_DATA_DEFAULT_LOCATION).join("cycle.json"), + ) { + Ok(cycle) => Some(cycle.cycle), + Err(_) => None, + }; + + // Determine if we are bundled ONLY and the bundled cycle is newer than the installed (old bundled) cycle + let bundled_updated = if is_bundled.is_some() && is_bundled.unwrap() { + if installed_cycle.is_some() && bundled_cycle.is_some() { + bundled_cycle.unwrap() > installed_cycle.unwrap() + } else { + false + } + } else { + false + }; + + // If there is no addon config, we can assume that we need to copy the bundled database to the work location + let need_to_copy = is_bundled.is_none(); + + // If we are bundled and the installed cycle is older than the bundled cycle, we need to copy the bundled database to the work location. Or if we haven't installed anything yet, we need to copy the bundled database to the work location + if bundled_updated || need_to_copy { + match util::copy_files_to_folder( + &Path::new(consts::NAVIGATION_DATA_DEFAULT_LOCATION), + &Path::new(consts::NAVIGATION_DATA_WORK_LOCATION), + ) { + Ok(_) => { + // Set the internal state to bundled + let res = meta::set_internal_state(InternalState { is_bundled: true }); + if let Err(e) = res { + println!("[NAVIGRAPH] Failed to set internal state: {}", e); + } + }, + Err(e) => { + println!( + "[NAVIGRAPH] Failed to copy database from default location to work location: {}", + e + ); + return; + }, + } + } + + // Finally, set the active database + if path_exists(&Path::new(consts::NAVIGATION_DATA_WORK_LOCATION)) { + match self.database.set_active_database(consts::NAVIGATION_DATA_WORK_LOCATION.to_owned()) { + Ok(_) => { + println!("[NAVIGRAPH] Loaded database"); + }, + Err(e) => { + println!("[NAVIGRAPH] Failed to load database: {}", e); + }, + } + } else { + println!("[NAVIGRAPH] Failed to load database: there is no installed database"); + } + } + + fn on_download_finish(&mut self) { + match navigation_database::util::find_sqlite_file(consts::NAVIGATION_DATA_WORK_LOCATION) { + Ok(path) => { + match self.database.set_active_database(path) { + Ok(_) => {}, + Err(e) => { + println!("[NAVIGRAPH] Failed to set active database: {}", e); + }, + }; + }, + Err(_) => {}, + } } fn process_queue(&mut self) { @@ -128,19 +228,18 @@ impl<'a> Dispatcher<'a> { self.database.close_connection(); // Now we can download the navigation data - self.downloader.download(Rc::clone(task)) + self.downloader.download(Rc::clone(task)); }, FunctionType::SetDownloadOptions => { Dispatcher::execute_task(task.clone(), |t| self.downloader.set_download_options(t)) }, - FunctionType::SetActiveDatabase => Dispatcher::execute_task(task.clone(), |t| { - let params = t.borrow().parse_data_as::()?; - self.database.set_active_database(params.path)?; - - t.borrow_mut().status = TaskStatus::Success(None); + FunctionType::GetNavigationDataInstallStatus => { + // We can't use the execute_task function here because the download process doesn't finish in the + // function call, which results in slightly "messier" code - Ok(()) - }), + // We first need to initialize the network request and then wait for the response + meta::start_network_request(Rc::clone(task)) + }, FunctionType::ExecuteSQLQuery => Dispatcher::execute_task(task.clone(), |t| { let params = t.borrow().parse_data_as::()?; let data = self.database.execute_sql_query(params.sql, params.params)?; @@ -357,6 +456,33 @@ impl<'a> Dispatcher<'a> { } } + // Network request tasks + for task in queue + .iter() + .filter(|task| task.borrow().status == TaskStatus::InProgress) + { + let response_state = match task.borrow().associated_network_request { + Some(ref request) => request.response_state(), + None => continue, + }; + let function_type = task.borrow().function_type; + if response_state == NetworkRequestState::DataReady { + match function_type { + FunctionType::GetNavigationDataInstallStatus => { + println!("[NAVIGRAPH] Network request completed, getting install status"); + meta::get_navigation_data_install_status(Rc::clone(task)); + println!("[NAVIGRAPH] Install status task completed"); + }, + _ => { + // Should not happen for now + println!("[NAVIGRAPH] Network request completed but no handler for this type of request"); + }, + } + } else if response_state == NetworkRequestState::Failed { + task.borrow_mut().status = TaskStatus::Failure("Network request failed".to_owned()); + } + } + // Process completed tasks (everything should at least be in progress at this point) queue.retain(|task| { if let TaskStatus::InProgress = task.borrow().status { @@ -416,6 +542,7 @@ impl<'a> Dispatcher<'a> { id: json_result.id, data: json_result.data, status: TaskStatus::NotStarted, + associated_network_request: None, }))); Ok(()) diff --git a/src/wasm/src/download/downloader.rs b/src/wasm/src/download/downloader.rs index b253fa5c..2722cd54 100644 --- a/src/wasm/src/download/downloader.rs +++ b/src/wasm/src/download/downloader.rs @@ -3,9 +3,11 @@ use std::{cell::RefCell, io::Cursor, path::PathBuf, rc::Rc}; use msfs::network::*; use crate::{ + consts, dispatcher::{Dispatcher, Task, TaskStatus}, download::zip_handler::{BatchReturn, ZipFileHandler}, json_structs::{events, params}, + meta::{self, InternalState}, }; pub struct DownloadOptions { @@ -18,12 +20,13 @@ pub enum DownloadStatus { Downloading, CleaningDestination, Extracting, + Downloaded, Failed(String), } pub struct NavigationDataDownloader { zip_handler: RefCell>>>>, - download_status: RefCell, + pub download_status: RefCell, options: RefCell, task: RefCell>>>, } @@ -60,8 +63,12 @@ impl NavigationDataDownloader { let mut borrowed_task = borrowed_task.as_ref().unwrap().borrow_mut(); borrowed_task.status = TaskStatus::Success(None); } - - self.reset_download(); + self.download_status.replace(DownloadStatus::Downloaded); + // Update the internal state + let res = meta::set_internal_state(InternalState { is_bundled: false }); + if let Err(e) = res { + println!("[NAVIGRAPH] Failed to set internal state: {}", e); + } }, Ok(BatchReturn::MoreFilesToDelete) => { self.download_status.replace(DownloadStatus::CleaningDestination); @@ -133,7 +140,7 @@ impl NavigationDataDownloader { match NetworkRequestBuilder::new(¶ms.url) .unwrap() .with_callback(move |network_request, status_code| { - captured_self.request_finished_callback(network_request, status_code, params.path) + captured_self.request_finished_callback(network_request, status_code) }) .get() { @@ -170,7 +177,7 @@ impl NavigationDataDownloader { Dispatcher::send_event(events::EventType::DownloadProgress, Some(serialized_data)); } - fn request_finished_callback(&self, request: NetworkRequest, status_code: i32, folder: String) { + fn request_finished_callback(&self, request: NetworkRequest, status_code: i32) { // Fail if the status code is not 200 if status_code != 200 { self.download_status.replace(DownloadStatus::Failed(format!( @@ -180,7 +187,7 @@ impl NavigationDataDownloader { return; } - let path = PathBuf::from(format!("\\work/{}", folder)); + let path = PathBuf::from(consts::NAVIGATION_DATA_WORK_LOCATION); // Check the data from the request let data = request.data(); @@ -228,6 +235,13 @@ impl NavigationDataDownloader { self.task.replace(None); } + /// This must be called to clear the download status and reset the download + pub fn acknowledge_download(&self) { + self.download_status.replace(DownloadStatus::NoDownload); + + self.reset_download(); + } + fn check_failed_and_get_message(&self) -> Option { let borrowed_status = self.download_status.borrow(); if let DownloadStatus::Failed(ref message) = *borrowed_status { diff --git a/src/wasm/src/json_structs.rs b/src/wasm/src/json_structs.rs index e4ae2612..d77c1907 100644 --- a/src/wasm/src/json_structs.rs +++ b/src/wasm/src/json_structs.rs @@ -2,14 +2,14 @@ /// Contains structs relating to functions pub mod functions { - #[derive(serde::Deserialize, Clone, Copy)] + #[derive(serde::Deserialize, Clone, Copy, PartialEq)] pub enum FunctionType { /// `DownloadNavigationDataParams` DownloadNavigationData, /// `SetDownloadOptionsParams` SetDownloadOptions, - /// `SetActiveDatabaseParams` - SetActiveDatabase, + /// `GetNavigationDataInstallStatus` + GetNavigationDataInstallStatus, /// `ExecuteSQLQueryParams` ExecuteSQLQuery, @@ -138,8 +138,6 @@ pub mod params { #[derive(serde::Deserialize)] pub struct DownloadNavigationDataParams { - /// Path to the folder to download to - pub path: String, /// URL to download from pub url: String, } @@ -150,12 +148,6 @@ pub mod params { pub batch_size: usize, } - #[derive(serde::Deserialize)] - pub struct SetActiveDatabaseParams { - /// Path to the DFD database file - pub path: String, - } - #[derive(serde::Deserialize)] pub struct ExecuteSQLQueryParams { /// SQL query to execute diff --git a/src/wasm/src/lib.rs b/src/wasm/src/lib.rs index c5d34cec..3dbb4bd9 100644 --- a/src/wasm/src/lib.rs +++ b/src/wasm/src/lib.rs @@ -1,6 +1,9 @@ +mod consts; mod dispatcher; mod download; mod json_structs; +mod meta; +mod network_helper; mod util; #[msfs::gauge(name=navigation_data_interface)] diff --git a/src/wasm/src/meta.rs b/src/wasm/src/meta.rs new file mode 100644 index 00000000..37e9ba76 --- /dev/null +++ b/src/wasm/src/meta.rs @@ -0,0 +1,220 @@ +use std::{ + cell::RefCell, + error::Error, + path::{Path, PathBuf}, + rc::Rc, +}; + +use msfs::network::NetworkRequestState; + +use crate::{ + consts, + dispatcher::{Task, TaskStatus}, + network_helper::{Method, NetworkHelper}, + util::path_exists, +}; + +#[derive(serde::Serialize, serde::Deserialize, Debug)] +pub struct InternalState { + pub is_bundled: bool, +} + +impl Default for InternalState { + fn default() -> Self { + Self { is_bundled: false } + } +} + +#[derive(serde::Serialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum InstallStatus { + Bundled, + Manual, + None, +} + +#[derive(serde::Serialize, Debug)] +pub struct NavigationDataStatus { + pub status: InstallStatus, + #[serde(rename = "installedFormat")] + pub installed_format: Option, + #[serde(rename = "installedRevision")] + pub installed_revision: Option, + #[serde(rename = "installedCycle")] + pub installed_cycle: Option, + #[serde(rename = "installedPath")] + pub install_path: Option, + #[serde(rename = "validityPeriod")] + pub validity_period: Option, + #[serde(rename = "latestCycle")] + pub latest_cycle: String, +} + +#[derive(serde::Deserialize)] +pub struct CurrentCycleResponse { + pub name: String, + pub version: String, + pub configuration: String, + pub cycle: String, +} + +#[derive(serde::Deserialize)] +pub struct InstalledNavigationDataCycleInfo { + pub cycle: String, + pub revision: String, + pub name: String, + pub format: String, + #[serde(rename = "validityPeriod")] + pub validity_period: String, +} + +pub fn get_internal_state() -> Result> { + let config_path = Path::new(consts::NAVIGATION_DATA_INTERNAL_CONFIG_LOCATION); + if !path_exists(&config_path) { + return Err("Internal config file does not exist")?; + } + + let config_file = std::fs::File::open(config_path)?; + let internal_state: InternalState = serde_json::from_reader(config_file)?; + + Ok(internal_state) +} + +pub fn set_internal_state(internal_state: InternalState) -> Result<(), Box> { + let config_path = Path::new(consts::NAVIGATION_DATA_INTERNAL_CONFIG_LOCATION); + let config_file = std::fs::File::create(config_path)?; + serde_json::to_writer(config_file, &internal_state)?; + + Ok(()) +} + +pub fn start_network_request(task: Rc>) { + let request = NetworkHelper::make_request("https://navdata.api.navigraph.com/info", Method::Get, None, None); + let request = match request { + Ok(request) => request, + Err(e) => { + task.borrow_mut().status = TaskStatus::Failure(e.to_string()); + return; + }, + }; + task.borrow_mut().associated_network_request = Some(request); +} + +pub fn get_installed_cycle_from_json(path: &Path) -> Result> { + let json_file = std::fs::File::open(path)?; + let installed_cycle_info: InstalledNavigationDataCycleInfo = serde_json::from_reader(json_file)?; + + Ok(installed_cycle_info) +} + +pub fn get_navigation_data_install_status(task: Rc>) { + let response_bytes = match task.borrow().associated_network_request.as_ref() { + Some(request) => { + if request.response_state() == NetworkRequestState::DataReady { + let response = request.get_response(); + match response { + Ok(response) => response, + Err(e) => { + task.borrow_mut().status = TaskStatus::Failure(e.to_string()); + return; + }, + } + } else { + return; + } + }, + None => { + task.borrow_mut().status = TaskStatus::Failure("No associated network request".to_string()); + return; + }, + }; + + let response_struct: CurrentCycleResponse = match serde_json::from_slice(&response_bytes) { + Ok(response_struct) => response_struct, + Err(e) => { + task.borrow_mut().status = TaskStatus::Failure(e.to_string()); + return; + }, + }; + + // figure out install status + let found_downloaded = path_exists(Path::new(consts::NAVIGATION_DATA_WORK_LOCATION)); + + let found_bundled = get_internal_state() + .map(|internal_state| internal_state.is_bundled) + .unwrap_or(false); + + // Check bundled first, as downloaded and bundled are both possible + let status = if found_bundled { + InstallStatus::Bundled + } else if found_downloaded { + InstallStatus::Manual + } else { + InstallStatus::None + }; + + // Open JSON + let json_path = if status != InstallStatus::None { + Some(PathBuf::from(consts::NAVIGATION_DATA_WORK_LOCATION).join("cycle.json")) + } else { + None + }; + + let installed_cycle_info = match json_path { + Some(json_path) => { + let json_file = match std::fs::File::open(json_path) { + Ok(json_file) => json_file, + Err(e) => { + task.borrow_mut().status = TaskStatus::Failure(e.to_string()); + return; + }, + }; + + let installed_cycle_info: InstalledNavigationDataCycleInfo = match serde_json::from_reader(json_file) { + Ok(installed_cycle_info) => installed_cycle_info, + Err(e) => { + task.borrow_mut().status = TaskStatus::Failure(e.to_string()); + return; + }, + }; + + Some(installed_cycle_info) + }, + None => None, + }; + + let navigation_data_status = NavigationDataStatus { + status, + installed_format: match &installed_cycle_info { + Some(installed_cycle_info) => Some(installed_cycle_info.format.clone()), + None => None, + }, + installed_revision: match &installed_cycle_info { + Some(installed_cycle_info) => Some(installed_cycle_info.revision.clone()), + None => None, + }, + installed_cycle: match &installed_cycle_info { + Some(installed_cycle_info) => Some(installed_cycle_info.cycle.clone()), + None => None, + }, + install_path: if status == InstallStatus::Manual { + Some(consts::NAVIGATION_DATA_WORK_LOCATION.to_string()) + } else { + None + }, + validity_period: match &installed_cycle_info { + Some(installed_cycle_info) => Some(installed_cycle_info.validity_period.clone()), + None => None, + }, + latest_cycle: response_struct.cycle, + }; + + let status_as_value = match serde_json::to_value(&navigation_data_status) { + Ok(status_as_value) => status_as_value, + Err(e) => { + task.borrow_mut().status = TaskStatus::Failure(e.to_string()); + return; + }, + }; + + task.borrow_mut().status = TaskStatus::Success(Some(status_as_value)); +} diff --git a/src/wasm/src/network_helper.rs b/src/wasm/src/network_helper.rs new file mode 100644 index 00000000..a9005491 --- /dev/null +++ b/src/wasm/src/network_helper.rs @@ -0,0 +1,54 @@ +use std::error::Error; + +use msfs::network::{NetworkRequest, NetworkRequestBuilder, NetworkRequestState}; + +pub enum Method { + Get, +} + +pub struct NetworkHelper { + request: NetworkRequest, +} + +impl NetworkHelper { + pub fn make_request( + url: &str, method: Method, headers: Option>, data: Option<&mut [u8]>, + ) -> Result> { + let mut builder = NetworkRequestBuilder::new(url).ok_or("Failed to create NetworkRequestBuilder")?; + + // Add headers + if let Some(headers) = headers { + for header in headers { + let new_builder = builder.with_header(header).ok_or("Failed to add header")?; + builder = new_builder; + } + } + + // Add data + if let Some(data) = data { + let new_builder = builder.with_data(data); + builder = new_builder; + } + + // Send request + let request = match method { + Method::Get => builder.get().ok_or("Failed to send GET request")?, + }; + + Ok(Self { request }) + } + + pub fn response_state(&self) -> NetworkRequestState { + self.request.state() + } + + pub fn get_response(&self) -> Result, Box> { + if self.request.state() != NetworkRequestState::DataReady { + return Err("Request not finished yet".into()); + } + + let data = self.request.data().ok_or("Failed to get data")?; + + Ok(data) + } +} diff --git a/src/wasm/src/util.rs b/src/wasm/src/util.rs index 62a62f69..5bcca068 100644 --- a/src/wasm/src/util.rs +++ b/src/wasm/src/util.rs @@ -2,7 +2,9 @@ use std::{fs, io, path::Path}; use navigation_database::util::{get_path_type, PathType}; -pub fn path_exists(path: &Path) -> bool { get_path_type(path) != PathType::DoesNotExist } +pub fn path_exists(path: &Path) -> bool { + get_path_type(path) != PathType::DoesNotExist +} pub fn delete_folder_recursively(path: &Path, batch_size: Option) -> io::Result<()> { // Make sure we are deleting a directory (and in turn that it exists) @@ -22,10 +24,15 @@ pub fn delete_folder_recursively(path: &Path, batch_size: Option) -> io:: // After we have collected the entries, delete them for entry in entries { let path = entry.path(); - if get_path_type(&path) == PathType::Directory { + let path_type = get_path_type(&path); + + if path_type == PathType::Directory { delete_folder_recursively(&path, batch_size)?; - } else { + } else if path_type == PathType::File { fs::remove_file(&path)?; + } else if let None = path.extension() { + // There are edge cases where completely empty directories are created and can't be deleted. They get registered as "unknown" path type so we need to check if the path has an extension (which would tell us if it's a file or a directory), and if it doesn't, we delete it as a directory + let _ = fs::remove_dir(&path); // this can fail silently, but we don't care since there also might be cases where a file literally doesn't exist } } // Check if the directory is empty. If it is, delete it @@ -42,4 +49,34 @@ pub fn delete_folder_recursively(path: &Path, batch_size: Option) -> io:: Ok(()) } -pub fn trim_null_terminator(s: &str) -> &str { s.trim_end_matches(char::from(0)) } +pub fn copy_files_to_folder(from: &Path, to: &Path) -> io::Result<()> { + // Make sure we are copying a directory (and in turn that it exists) + if get_path_type(from) != PathType::Directory { + return Ok(()); + } + // Let's clear the directory we are copying to + delete_folder_recursively(to, None)?; + // Create the directory we are copying to + fs::create_dir(to)?; + // Collect the entries that we will copy + let entries = fs::read_dir(from)?.collect::, _>>()?; + // Copy the entries + for entry in entries { + let path = entry.path(); + let path_type = get_path_type(&path); + + if path_type == PathType::Directory { + let new_dir = to.join(path.file_name().unwrap()); + fs::create_dir(&new_dir)?; + copy_files_to_folder(&path, &new_dir)?; + } else if path_type == PathType::File { + fs::copy(&path, to.join(path.file_name().unwrap()))?; + } + } + + Ok(()) +} + +pub fn trim_null_terminator(s: &str) -> &str { + s.trim_end_matches(char::from(0)) +}