mirror of
https://github.com/open-goal/launcher.git
synced 2024-10-20 04:57:38 -04:00
frontend/backend: propagate install logs to frontend
This commit is contained in:
parent
e8cdd57e8a
commit
dc23e2931c
19
README.md
19
README.md
|
@ -33,20 +33,7 @@ We are using Tauri to build a native app, but still with simple Web technology.
|
|||
|
||||
> Additionally, this presumes your environment has WebView2 (windows) or webkit2 (linux) already available. This is a requirement for end-users as well! Many modern OSes already ship with such a thing, but it's something we'll need to investigate.
|
||||
|
||||
- `npm install`
|
||||
- `npm run tauri dev`
|
||||
- `yarn install`
|
||||
- `yarn tauri dev`
|
||||
|
||||
This builds the app with Tauri (this is a rust compilation, the first run will take a while) and the frontend is served via Vite -- a nice web server that will hot-reload any changes as you develop.
|
||||
|
||||
## Release Process
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
jak-project->>jak-project: New tag is manually cut and built
|
||||
jak-project->>launcher: Repository Dispatch to start release
|
||||
launcher->>launcher: Alternatively, manually triggered release here
|
||||
launcher->>launcher: Build App for all supported platforms
|
||||
launcher->>launcher: Publish release and update latest release metadata file in repo
|
||||
website->>GitHub API: Website will display latest release
|
||||
app->>launcher: Detect new version and will prompt the user to update
|
||||
```
|
||||
This builds the app with Tauri (this is a rust compilation, the first run will take a while) and the frontend is served via Vite -- a fast alternative to webpack that offers HMR.
|
||||
|
|
|
@ -27,8 +27,10 @@
|
|||
"@tauri-apps/cli": "^1.2.3",
|
||||
"@tauri-apps/tauricon": "github:tauri-apps/tauricon",
|
||||
"@tsconfig/svelte": "^3.0.0",
|
||||
"ansi-to-span": "^0.0.1",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"classnames": "^2.3.2",
|
||||
"escape-html": "^1.0.3",
|
||||
"execa": "^7.0.0",
|
||||
"flowbite": "^1.6.3",
|
||||
"flowbite-svelte": "^0.29.7",
|
||||
|
|
|
@ -38,5 +38,3 @@ if (existsSync(`src-tauri/bin/glewinfo${extension}`)) {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
use std::{
|
||||
collections::HashMap,
|
||||
io::BufRead,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
process::{Command, ExitStatus},
|
||||
};
|
||||
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::{
|
||||
config::LauncherConfig,
|
||||
util::file::{create_dir, overwrite_dir},
|
||||
util::file::{create_dir, overwrite_dir, read_lines_in_file},
|
||||
};
|
||||
|
||||
use super::CommandError;
|
||||
|
@ -62,39 +64,6 @@ fn common_prelude(
|
|||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_data_directory(
|
||||
config: tauri::State<'_, tokio::sync::Mutex<LauncherConfig>>,
|
||||
game_name: String,
|
||||
) -> Result<(), CommandError> {
|
||||
let config_lock = config.lock().await;
|
||||
let config_info = common_prelude(&config_lock)?;
|
||||
|
||||
let src_dir = config_info
|
||||
.install_path
|
||||
.join("versions")
|
||||
.join(config_info.active_version_folder)
|
||||
.join(config_info.active_version)
|
||||
.join("data");
|
||||
|
||||
let dst_dir = config_info
|
||||
.install_path
|
||||
.join("active")
|
||||
.join(game_name)
|
||||
.join("data");
|
||||
|
||||
info!("Copying {} into {}", src_dir.display(), dst_dir.display());
|
||||
|
||||
overwrite_dir(&src_dir, &dst_dir).map_err(|err| {
|
||||
CommandError::Installation(format!(
|
||||
"Unable to copy data directory: '{}'",
|
||||
err.to_string()
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct LauncherErrorCode {
|
||||
msg: String,
|
||||
|
@ -210,13 +179,61 @@ fn create_log_file(
|
|||
Ok(file)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstallStepOutput {
|
||||
pub success: bool,
|
||||
pub msg: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_data_directory(
|
||||
config: tauri::State<'_, tokio::sync::Mutex<LauncherConfig>>,
|
||||
game_name: String,
|
||||
) -> Result<InstallStepOutput, CommandError> {
|
||||
let config_lock = config.lock().await;
|
||||
let config_info = common_prelude(&config_lock)?;
|
||||
|
||||
let src_dir = config_info
|
||||
.install_path
|
||||
.join("versions")
|
||||
.join(config_info.active_version_folder)
|
||||
.join(config_info.active_version)
|
||||
.join("data");
|
||||
|
||||
let dst_dir = config_info
|
||||
.install_path
|
||||
.join("active")
|
||||
.join(game_name)
|
||||
.join("data");
|
||||
|
||||
info!("Copying {} into {}", src_dir.display(), dst_dir.display());
|
||||
|
||||
overwrite_dir(&src_dir, &dst_dir).map_err(|err| {
|
||||
CommandError::Installation(format!(
|
||||
"Unable to copy data directory: '{}'",
|
||||
err.to_string()
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(InstallStepOutput {
|
||||
success: true,
|
||||
msg: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct LogPayload {
|
||||
stdout: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extract_and_validate_iso(
|
||||
config: tauri::State<'_, tokio::sync::Mutex<LauncherConfig>>,
|
||||
app_handle: tauri::AppHandle,
|
||||
path_to_iso: String,
|
||||
game_name: String,
|
||||
) -> Result<(), CommandError> {
|
||||
) -> Result<InstallStepOutput, CommandError> {
|
||||
let config_lock = config.lock().await;
|
||||
let config_info = common_prelude(&config_lock)?;
|
||||
|
||||
|
@ -237,14 +254,44 @@ pub async fn extract_and_validate_iso(
|
|||
// This is the first install step, reset the file
|
||||
let log_file = create_log_file(&app_handle, "extractor.log", false)?;
|
||||
|
||||
// TODO - exit codes
|
||||
let output = Command::new(exec_info.executable_path)
|
||||
.args(args)
|
||||
.current_dir(exec_info.executable_dir)
|
||||
.stdout(log_file.try_clone().unwrap())
|
||||
.stderr(log_file)
|
||||
.stderr(log_file.try_clone().unwrap())
|
||||
.output()?;
|
||||
Ok(())
|
||||
// TODO - we should instead capture the logs while simulanteously streaming them to a file
|
||||
// right now we aren't so I just read the file after it's done
|
||||
app_handle.emit_all(
|
||||
"updateJobLogs",
|
||||
LogPayload {
|
||||
stdout: read_lines_in_file(
|
||||
&app_handle
|
||||
.path_resolver()
|
||||
.app_log_dir()
|
||||
.unwrap()
|
||||
.join("extractor.log"),
|
||||
)
|
||||
.expect("TODO"),
|
||||
},
|
||||
)?;
|
||||
match output.status.code() {
|
||||
Some(code) => {
|
||||
let error_code_map = get_error_codes(&config_info);
|
||||
let default_error = LauncherErrorCode {
|
||||
msg: format!("Unexpected error occured with code {}", code).to_owned(),
|
||||
};
|
||||
let message = error_code_map.get(&code).unwrap_or(&default_error);
|
||||
Ok(InstallStepOutput {
|
||||
success: false,
|
||||
msg: Some(message.msg.clone()),
|
||||
})
|
||||
}
|
||||
None => Ok(InstallStepOutput {
|
||||
success: true,
|
||||
msg: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
@ -253,7 +300,7 @@ pub async fn run_decompiler(
|
|||
app_handle: tauri::AppHandle,
|
||||
path_to_iso: String,
|
||||
game_name: String,
|
||||
) -> Result<(), CommandError> {
|
||||
) -> Result<InstallStepOutput, CommandError> {
|
||||
let config_lock = config.lock().await;
|
||||
let config_info = common_prelude(&config_lock)?;
|
||||
|
||||
|
@ -269,7 +316,6 @@ pub async fn run_decompiler(
|
|||
.to_string();
|
||||
}
|
||||
|
||||
// TODO - handle error codes
|
||||
let log_file = create_log_file(&app_handle, "extractor.log", true)?;
|
||||
let output = Command::new(&exec_info.executable_path)
|
||||
.args([
|
||||
|
@ -282,7 +328,38 @@ pub async fn run_decompiler(
|
|||
.stderr(log_file)
|
||||
.current_dir(exec_info.executable_dir)
|
||||
.output()?;
|
||||
Ok(())
|
||||
// TODO - we should instead capture the logs while simulanteously streaming them to a file
|
||||
// right now we aren't so I just read the file after it's done
|
||||
app_handle.emit_all(
|
||||
"updateJobLogs",
|
||||
LogPayload {
|
||||
stdout: read_lines_in_file(
|
||||
&app_handle
|
||||
.path_resolver()
|
||||
.app_log_dir()
|
||||
.unwrap()
|
||||
.join("extractor.log"),
|
||||
)
|
||||
.expect("TODO"),
|
||||
},
|
||||
)?;
|
||||
match output.status.code() {
|
||||
Some(code) => {
|
||||
let error_code_map = get_error_codes(&config_info);
|
||||
let default_error = LauncherErrorCode {
|
||||
msg: format!("Unexpected error occured with code {}", code).to_owned(),
|
||||
};
|
||||
let message = error_code_map.get(&code).unwrap_or(&default_error);
|
||||
Ok(InstallStepOutput {
|
||||
success: false,
|
||||
msg: Some(message.msg.clone()),
|
||||
})
|
||||
}
|
||||
None => Ok(InstallStepOutput {
|
||||
success: true,
|
||||
msg: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
@ -291,7 +368,7 @@ pub async fn run_compiler(
|
|||
app_handle: tauri::AppHandle,
|
||||
path_to_iso: String,
|
||||
game_name: String,
|
||||
) -> Result<(), CommandError> {
|
||||
) -> Result<InstallStepOutput, CommandError> {
|
||||
let config_lock = config.lock().await;
|
||||
let config_info = common_prelude(&config_lock)?;
|
||||
|
||||
|
@ -307,7 +384,6 @@ pub async fn run_compiler(
|
|||
.to_string();
|
||||
}
|
||||
|
||||
// TODO - handle error codes
|
||||
let log_file = create_log_file(&app_handle, "extractor.log", true)?;
|
||||
let output = Command::new(&exec_info.executable_path)
|
||||
.args([
|
||||
|
@ -320,7 +396,38 @@ pub async fn run_compiler(
|
|||
.stderr(log_file)
|
||||
.current_dir(exec_info.executable_dir)
|
||||
.output()?;
|
||||
Ok(())
|
||||
// TODO - we should instead capture the logs while simulanteously streaming them to a file
|
||||
// right now we aren't so I just read the file after it's done
|
||||
app_handle.emit_all(
|
||||
"updateJobLogs",
|
||||
LogPayload {
|
||||
stdout: read_lines_in_file(
|
||||
&app_handle
|
||||
.path_resolver()
|
||||
.app_log_dir()
|
||||
.unwrap()
|
||||
.join("extractor.log"),
|
||||
)
|
||||
.expect("TODO"),
|
||||
},
|
||||
)?;
|
||||
match output.status.code() {
|
||||
Some(code) => {
|
||||
let error_code_map = get_error_codes(&config_info);
|
||||
let default_error = LauncherErrorCode {
|
||||
msg: format!("Unexpected error occured with code {}", code).to_owned(),
|
||||
};
|
||||
let message = error_code_map.get(&code).unwrap_or(&default_error);
|
||||
Ok(InstallStepOutput {
|
||||
success: false,
|
||||
msg: Some(message.msg.clone()),
|
||||
})
|
||||
}
|
||||
None => Ok(InstallStepOutput {
|
||||
success: true,
|
||||
msg: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
@ -337,7 +444,6 @@ pub async fn open_repl(
|
|||
|
||||
let data_folder = get_data_dir(&config_info, &game_name)?;
|
||||
let exec_info = get_exec_location(&config_info, "goalc")?;
|
||||
// TODO - handle error codes
|
||||
let output = Command::new("cmd")
|
||||
.args([
|
||||
"/K",
|
||||
|
@ -371,7 +477,6 @@ pub async fn launch_game(
|
|||
}
|
||||
args.push("-proj-path".to_string());
|
||||
args.push(data_folder.to_string_lossy().into_owned());
|
||||
// TODO - handle error codes
|
||||
let log_file = create_log_file(&app_handle, "game.log", false)?;
|
||||
let output = Command::new(exec_info.executable_path)
|
||||
.args(args)
|
||||
|
|
|
@ -50,7 +50,7 @@ pub async fn is_avx_requirement_met(
|
|||
match config_lock.requirements.avx {
|
||||
None => {
|
||||
if is_x86_feature_detected!("avx") || is_x86_feature_detected!("avx2") {
|
||||
config_lock.requirements.avx = Some(false);
|
||||
config_lock.requirements.avx = Some(true);
|
||||
} else {
|
||||
config_lock.requirements.avx = Some(false);
|
||||
}
|
||||
|
|
|
@ -92,9 +92,9 @@ fn main() {
|
|||
commands::config::get_installed_version_folder,
|
||||
commands::config::get_installed_version,
|
||||
commands::config::is_avx_requirement_met,
|
||||
commands::config::is_avx_supported,
|
||||
commands::config::is_game_installed,
|
||||
commands::config::is_opengl_requirement_met,
|
||||
commands::config::set_opengl_requirement_met,
|
||||
commands::config::save_active_version_change,
|
||||
commands::config::set_install_directory,
|
||||
commands::game::reset_game_settings,
|
||||
|
|
|
@ -32,3 +32,7 @@ pub fn overwrite_dir(src: &PathBuf, dst: &PathBuf) -> Result<(), fs_extra::error
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_lines_in_file(path: &PathBuf) -> Result<String, Box<dyn std::error::Error>> {
|
||||
Ok(std::fs::read_to_string(path)?)
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
} from "$lib/rpc/extractor";
|
||||
import { finalizeInstallation } from "$lib/rpc/config";
|
||||
import { generateSupportPackage } from "$lib/rpc/support";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export let activeGame: SupportedGame;
|
||||
export let jobType: Job;
|
||||
|
@ -27,6 +28,11 @@
|
|||
// It's used to provide almost the same interface as the normal installation, with logs, etc
|
||||
// but for arbitrary jobs. Such as updating versions, decompiling, or compiling.
|
||||
onMount(async () => {
|
||||
const unlistenLogListener = await listen("newJobLogs", async (event) => {
|
||||
console.log(event.payload);
|
||||
progressTracker.updateLogs(event.payload["stdout"]);
|
||||
});
|
||||
|
||||
if (jobType === "decompile") {
|
||||
progressTracker.init([
|
||||
{
|
||||
|
@ -95,7 +101,7 @@
|
|||
|
||||
<div class="flex flex-col justify-content">
|
||||
<Progress />
|
||||
{#if $progressTracker.logs}
|
||||
{#if $progressTracker.logs.length > 0}
|
||||
<LogViewer />
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -22,12 +22,13 @@
|
|||
import { progressTracker } from "$lib/stores/ProgressStore";
|
||||
import { generateSupportPackage } from "$lib/rpc/support";
|
||||
import { isOpenGLVersionSupported } from "$lib/sidecars/glewinfo";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export let activeGame: SupportedGame;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let requirementsMet = false;
|
||||
let requirementsMet = true;
|
||||
let installing = false;
|
||||
|
||||
onMount(async () => {
|
||||
|
@ -39,6 +40,11 @@
|
|||
await setOpenGLRequirementMet(isOpenGLMet);
|
||||
}
|
||||
requirementsMet = isAvxMet && isOpenGLMet;
|
||||
|
||||
const unlistenLogListener = await listen("updateJobLogs", async (event) => {
|
||||
console.log(event.payload);
|
||||
progressTracker.updateLogs(event.payload["stdout"]);
|
||||
});
|
||||
});
|
||||
|
||||
async function install(viaFolder: boolean) {
|
||||
|
@ -94,7 +100,7 @@
|
|||
{:else if installing}
|
||||
<div class="flex flex-col justify-content">
|
||||
<Progress />
|
||||
{#if $progressTracker.logs}
|
||||
{#if $progressTracker.logs.length > 0}
|
||||
<LogViewer />
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -2,6 +2,12 @@
|
|||
import { progressTracker } from "$lib/stores/ProgressStore";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { Accordion, AccordionItem } from "flowbite-svelte";
|
||||
import { ansiSpan } from "ansi-to-span";
|
||||
import escapeHtml from "escape-html";
|
||||
|
||||
function convertLogColors(text) {
|
||||
return ansiSpan(escapeHtml(text)).replaceAll("\n", "<br/>");
|
||||
}
|
||||
</script>
|
||||
|
||||
<Accordion class="log-accordian" defaultClass="p-0">
|
||||
|
@ -14,8 +20,8 @@
|
|||
slot="default"
|
||||
class="bg-slate-900 px-4 max-h-60 overflow-y-scroll scrollbar"
|
||||
>
|
||||
<p class="py-4 text-clip overflow-hidden font-mono">
|
||||
{$progressTracker.logs}
|
||||
<p class="py-4 text-clip overflow-hidden font-mono log-output">
|
||||
{@html convertLogColors($progressTracker.logs)}
|
||||
</p>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
|
|
@ -67,3 +67,12 @@ body {
|
|||
/* TODO - no idea how else to customize the div that wraps the body slot in the accordian? */
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.log-output {
|
||||
font-size: 9pt;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.log-output > span {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
export type Job = "decompile" | "compile";
|
||||
export type Job = "decompile" | "compile" | "updateGame";
|
||||
|
|
|
@ -3,13 +3,14 @@ import { Command } from "@tauri-apps/api/shell";
|
|||
|
||||
export async function isOpenGLVersionSupported(
|
||||
version: string
|
||||
): Promise<boolean> {
|
||||
): Promise<boolean | undefined> {
|
||||
if ((await os.platform()) === "darwin") {
|
||||
console.log("[OG]: MacOS isn't supported, OpenGL won't work here!");
|
||||
return false;
|
||||
}
|
||||
// Otherwise, query for the version
|
||||
let command = Command.sidecar("bin/glewinfo", ["-version", version]);
|
||||
try {
|
||||
const output = await command.execute();
|
||||
if (output.code === 0) {
|
||||
return true;
|
||||
|
@ -21,4 +22,8 @@ export async function isOpenGLVersionSupported(
|
|||
stderr: output.stderr,
|
||||
});
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.log("[OG] Unable to check for OpenGL support via glewinfo", e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,6 +66,11 @@ function createProgressTracker() {
|
|||
val.steps[val.currentStep].status = "failed";
|
||||
return val;
|
||||
}),
|
||||
updateLogs: (logs: string[]) =>
|
||||
update((val) => {
|
||||
val.logs = logs;
|
||||
return val;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
15
yarn.lock
15
yarn.lock
|
@ -820,6 +820,11 @@ ansi-escapes@^4.2.1:
|
|||
dependencies:
|
||||
type-fest "^0.21.3"
|
||||
|
||||
ansi-html-community@^0.0.8:
|
||||
version "0.0.8"
|
||||
resolved "https://registry.yarnpkg.com/ansi-html-community/-/ansi-html-community-0.0.8.tgz#69fbc4d6ccbe383f9736934ae34c3f8290f1bf41"
|
||||
integrity sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==
|
||||
|
||||
ansi-regex@^2.0.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
|
||||
|
@ -842,6 +847,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
|
|||
dependencies:
|
||||
color-convert "^2.0.1"
|
||||
|
||||
ansi-to-span@^0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-to-span/-/ansi-to-span-0.0.1.tgz#22da2f8774862e1a632ade2d75c85ee937c2415e"
|
||||
integrity sha512-XLdA+dwBbMrzZQpsJ/ZDbAxuj8g81X2G/UWztQUVGQOaEfFVDk5XhROsaPhYXMMlpJ3WXQrPJ9ecrDyZxzJaUw==
|
||||
|
||||
any-base@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/any-base/-/any-base-1.1.0.tgz#ae101a62bc08a597b4c9ab5b7089d456630549fe"
|
||||
|
@ -1690,6 +1700,11 @@ escape-goat@^2.0.0:
|
|||
resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
|
||||
integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==
|
||||
|
||||
escape-html@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
||||
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
|
||||
|
||||
escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
|
||||
|
|
Loading…
Reference in a new issue