use crate::{
error::Context,
helpers::{app_paths::walk_builder, npm::PackageManager},
Error, ErrorExt, Result,
};
use itertools::Itertools;
use magic_string::MagicString;
use oxc_allocator::Allocator;
use oxc_ast::ast::*;
use oxc_parser::Parser;
use oxc_span::SourceType;
use std::{fs, path::Path};
mod partial_loader;
const RENAMED_MODULES: phf::Map<&str, &str> = phf::phf_map! {
"tauri" => "core",
"window" => "webviewWindow"
};
const PLUGINIFIED_MODULES: [&str; 11] = [
"cli",
"clipboard",
"dialog",
"fs",
"globalShortcut",
"http",
"notification",
"os",
"process",
"shell",
"updater",
];
const MODULES_MAP: phf::Map<&str, &str> = phf::phf_map! {
"@tauri-apps/api/tauri" => "@tauri-apps/api/core",
"@tauri-apps/api/window" => "@tauri-apps/api/webviewWindow",
"@tauri-apps/api/cli" => "@tauri-apps/plugin-cli",
"@tauri-apps/api/clipboard" => "@tauri-apps/plugin-clipboard-manager",
"@tauri-apps/api/dialog" => "@tauri-apps/plugin-dialog",
"@tauri-apps/api/fs" => "@tauri-apps/plugin-fs",
"@tauri-apps/api/globalShortcut" => "@tauri-apps/plugin-global-shortcut",
"@tauri-apps/api/http" => "@tauri-apps/plugin-http",
"@tauri-apps/api/notification" => "@tauri-apps/plugin-notification",
"@tauri-apps/api/os" => "@tauri-apps/plugin-os",
"@tauri-apps/api/process" => "@tauri-apps/plugin-process",
"@tauri-apps/api/shell" => "@tauri-apps/plugin-shell",
"@tauri-apps/api/updater" => "@tauri-apps/plugin-updater",
"tauri-plugin-sql-api" => "@tauri-apps/plugin-sql",
"tauri-plugin-store-api" => "@tauri-apps/plugin-store",
"tauri-plugin-upload-api" => "@tauri-apps/plugin-upload",
"tauri-plugin-fs-extra-api" => "@tauri-apps/plugin-fs",
"tauri-plugin-fs-watch-api" => "@tauri-apps/plugin-fs",
"tauri-plugin-autostart-api" => "@tauri-apps/plugin-autostart",
"tauri-plugin-websocket-api" => "@tauri-apps/plugin-websocket",
"tauri-plugin-positioner-api" => "@tauri-apps/plugin-positioner",
"tauri-plugin-stronghold-api" => "@tauri-apps/plugin-stronghold",
"tauri-plugin-window-state-api" => "@tauri-apps/plugin-window-state",
"tauri-plugin-authenticator-api" => "@tauri-apps/plugin-authenticator",
};
const JS_EXTENSIONS: &[&str] = &["js", "mjs", "jsx", "ts", "mts", "tsx", "svelte", "vue"];
pub fn migrate(frontend_dir: &Path) -> Result<Vec<String>> {
let mut new_npm_packages = Vec::new();
let mut new_plugins = Vec::new();
let mut npm_packages_to_remove = Vec::new();
let pre = env!("CARGO_PKG_VERSION_PRE");
let npm_version = if pre.is_empty() {
format!("{}.0.0", env!("CARGO_PKG_VERSION_MAJOR"))
} else {
format!(
"{}.0.0-{}.0",
env!("CARGO_PKG_VERSION_MAJOR"),
pre.split('.').next().unwrap()
)
};
let pm = PackageManager::from_project(frontend_dir);
for pkg in ["@tauri-apps/cli", "@tauri-apps/api"] {
let version = pm
.current_package_version(pkg, frontend_dir)
.unwrap_or_default()
.unwrap_or_default();
if version.starts_with('1') {
new_npm_packages.push(format!("{pkg}@^{npm_version}"));
}
}
for entry in walk_builder(frontend_dir).build().flatten() {
if entry.file_type().map(|t| t.is_file()).unwrap_or_default() {
let path = entry.path();
let ext = path.extension().unwrap_or_default();
if JS_EXTENSIONS.iter().any(|e| e == &ext) {
let js_contents =
std::fs::read_to_string(path).fs_context("failed to read JS file", path.to_path_buf())?;
let new_contents = migrate_imports(
path,
&js_contents,
&mut new_plugins,
&mut npm_packages_to_remove,
)?;
if new_contents != js_contents {
fs::write(path, new_contents)
.fs_context("failed to write JS file", path.to_path_buf())?;
}
}
}
}
if !npm_packages_to_remove.is_empty() {
npm_packages_to_remove.sort();
npm_packages_to_remove.dedup();
pm.remove(&npm_packages_to_remove, frontend_dir)
.context("Error removing npm packages")?;
}
if !new_npm_packages.is_empty() {
new_npm_packages.sort();
new_npm_packages.dedup();
pm.install(&new_npm_packages, frontend_dir)
.context("Error installing new npm packages")?;
}
Ok(new_plugins)
}
fn migrate_imports<'a>(
path: &'a Path,
js_source: &'a str,
new_plugins: &mut Vec<String>,
npm_packages_to_remove: &mut Vec<String>,
) -> crate::Result<String> {
let mut magic_js_source = MagicString::new(js_source);
let has_partial_js = path
.extension()
.is_some_and(|ext| ext == "vue" || ext == "svelte");
let sources = if !has_partial_js {
vec![(SourceType::from_path(path).unwrap(), js_source, 0i64)]
} else {
partial_loader::PartialLoader::parse(
path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
js_source,
)
.unwrap()
.into_iter()
.map(|s| (s.source_type, s.source_text, s.start as i64))
.collect()
};
for (source_type, js_source, script_start) in sources {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, js_source, source_type).parse();
if !ret.errors.is_empty() {
crate::error::bail!(
"failed to parse {} as valid Javascript/Typescript file",
path.display()
)
}
let mut program = ret.program;
let mut stmts_to_add = Vec::new();
let mut imports_to_add = Vec::new();
for import in program.body.iter_mut() {
if let Statement::ImportDeclaration(stmt) = import {
let module = stmt.source.value.as_str();
if let Some(&new_module) = MODULES_MAP.get(module) {
magic_js_source
.overwrite(
script_start + stmt.source.span.start as i64 + 1,
script_start + stmt.source.span.end as i64 - 1,
new_module,
Default::default(),
)
.map_err(|e| {
Error::Context(
"failed to replace import source".to_string(),
e.to_string().into(),
)
})?;
if let Some(plugin_name) = new_module.strip_prefix("@tauri-apps/plugin-") {
new_plugins.push(plugin_name.to_string());
}
if module.starts_with("tauri-plugin-") {
npm_packages_to_remove.push(module.to_string());
}
}
if !module.starts_with("@tauri-apps/api") {
continue;
}
let Some(specifiers) = &mut stmt.specifiers else {
continue;
};
for specifier in specifiers.iter() {
if let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier {
let new_identifier = match specifier.imported.name().as_str() {
"appWindow" if module == "@tauri-apps/api/window" => {
stmts_to_add.push("\nconst appWindow = getCurrentWebviewWindow()");
Some("getCurrentWebviewWindow")
}
import if PLUGINIFIED_MODULES.contains(&import) && module == "@tauri-apps/api" => {
let js_plugin: &str = MODULES_MAP[&format!("@tauri-apps/api/{import}")];
let (_, plugin_name) = js_plugin.split_once("plugin-").unwrap();
new_plugins.push(plugin_name.to_string());
if specifier.local.name.as_str() != import {
let local = &specifier.local.name;
imports_to_add.push(format!(
"\nimport * as {import} as {local} from \"{js_plugin}\""
));
} else {
imports_to_add.push(format!("\nimport * as {import} from \"{js_plugin}\""));
};
None
}
import if module == "@tauri-apps/api" => match RENAMED_MODULES.get(import) {
Some(m) => Some(*m),
None => continue,
},
_ => continue,
};
if let Some(new_identifier) = new_identifier {
magic_js_source
.overwrite(
script_start + specifier.span.start as i64,
script_start + specifier.span.end as i64,
new_identifier,
Default::default(),
)
.map_err(|e| {
Error::Context(
"failed to rename identifier".to_string(),
e.to_string().into(),
)
})?;
} else {
let start = specifier.span.start as usize;
let sliced = &js_source[start..];
let comma_or_bracket = sliced.chars().find_position(|&c| c == ',' || c == '}');
let end = match comma_or_bracket {
Some((n, ',')) => n + start + 1,
Some((_, '}')) => specifier.span.end as _,
_ => continue,
};
magic_js_source
.remove(script_start + start as i64, script_start + end as i64)
.map_err(|e| {
Error::Context(
"failed to remove identifier".to_string(),
e.to_string().into(),
)
})?;
}
}
}
}
}
let start = program
.body
.iter()
.rev()
.find(|s| matches!(s, Statement::ImportDeclaration(_)))
.map(|s| match s {
Statement::ImportDeclaration(s) => s.span.end,
_ => unreachable!(),
})
.unwrap_or(program.span.start);
if !imports_to_add.is_empty() {
for import in imports_to_add {
magic_js_source
.append_right(script_start as u32 + start, &import)
.map_err(|e| Error::Context("failed to add import".to_string(), e.to_string().into()))?;
}
}
if !stmts_to_add.is_empty() {
for stmt in stmts_to_add {
magic_js_source
.append_right(script_start as u32 + start, stmt)
.map_err(|e| {
Error::Context("failed to add statement".to_string(), e.to_string().into())
})?;
}
}
}
Ok(magic_js_source.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn migrates_vue() {
let input = r#"
<template>
<div>Tauri!</div>
</template>
<script setup>
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, dialog, cli as superCli } from "@tauri-apps/api";
import { appWindow } from "@tauri-apps/api/window";
import { convertFileSrc } from "@tauri-apps/api/tauri";
import { open } from "@tauri-apps/api/dialog";
import { register } from "@tauri-apps/api/globalShortcut";
import clipboard from "@tauri-apps/api/clipboard";
import * as fs from "@tauri-apps/api/fs";
import "./App.css";
</script>
<style>
.greeting {
color: red;
font-weight: bold;
}
</style>
"#;
let expected = r#"
<template>
<div>Tauri!</div>
</template>
<script setup>
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, } from "@tauri-apps/api";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { convertFileSrc } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { register } from "@tauri-apps/plugin-global-shortcut";
import clipboard from "@tauri-apps/plugin-clipboard-manager";
import * as fs from "@tauri-apps/plugin-fs";
import "./App.css";
import * as dialog from "@tauri-apps/plugin-dialog"
import * as cli as superCli from "@tauri-apps/plugin-cli"
const appWindow = getCurrentWebviewWindow()
</script>
<style>
.greeting {
color: red;
font-weight: bold;
}
</style>
"#;
let mut new_plugins = Vec::new();
let mut npm_packages_to_remove = Vec::new();
let migrated = migrate_imports(
Path::new("file.vue"),
input,
&mut new_plugins,
&mut npm_packages_to_remove,
)
.unwrap();
assert_eq!(migrated, expected);
assert_eq!(
new_plugins,
vec![
"dialog",
"cli",
"dialog",
"global-shortcut",
"clipboard-manager",
"fs"
]
);
assert_eq!(npm_packages_to_remove, Vec::<String>::new());
}
#[test]
fn migrates_svelte() {
let input = r#"
<form>
</form>
<script>
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, dialog, cli as superCli } from "@tauri-apps/api";
import { appWindow } from "@tauri-apps/api/window";
import { convertFileSrc } from "@tauri-apps/api/tauri";
import { open } from "@tauri-apps/api/dialog";
import { register } from "@tauri-apps/api/globalShortcut";
import clipboard from "@tauri-apps/api/clipboard";
import * as fs from "@tauri-apps/api/fs";
import "./App.css";
</script>
"#;
let expected = r#"
<form>
</form>
<script>
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, } from "@tauri-apps/api";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { convertFileSrc } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { register } from "@tauri-apps/plugin-global-shortcut";
import clipboard from "@tauri-apps/plugin-clipboard-manager";
import * as fs from "@tauri-apps/plugin-fs";
import "./App.css";
import * as dialog from "@tauri-apps/plugin-dialog"
import * as cli as superCli from "@tauri-apps/plugin-cli"
const appWindow = getCurrentWebviewWindow()
</script>
"#;
let mut new_plugins = Vec::new();
let mut npm_packages_to_remove = Vec::new();
let migrated = migrate_imports(
Path::new("file.svelte"),
input,
&mut new_plugins,
&mut npm_packages_to_remove,
)
.unwrap();
assert_eq!(migrated, expected);
assert_eq!(
new_plugins,
vec![
"dialog",
"cli",
"dialog",
"global-shortcut",
"clipboard-manager",
"fs"
]
);
assert_eq!(npm_packages_to_remove, Vec::<String>::new());
}
#[test]
fn migrates_js() {
let input = r#"
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, dialog, cli as superCli } from "@tauri-apps/api";
import { appWindow } from "@tauri-apps/api/window";
import { convertFileSrc } from "@tauri-apps/api/tauri";
import { open } from "@tauri-apps/api/dialog";
import { register } from "@tauri-apps/api/globalShortcut";
import clipboard from "@tauri-apps/api/clipboard";
import * as fs from "@tauri-apps/api/fs";
import { Store } from "tauri-plugin-store-api";
import Database from "tauri-plugin-sql-api";
import "./App.css";
function App() {
const [greetMsg, setGreetMsg] = useState("");
const [name, setName] = useState("");
async function greet() {
// Learn more about Tauri commands at https://v2.tauri.app/develop/calling-rust/#commands
setGreetMsg(await invoke("greet", { name }));
await open();
await dialog.save();
await convertFileSrc("");
const a = appWindow.label;
superCli.getMatches();
clipboard.readText();
fs.exists("");
}
return (
<div className="container">
<h1>Welcome to Tauri!</h1>
<div className="row">
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
<form
className="row"
onSubmit={(e) => {
e.preventDefault();
greet();
}}
>
<input
id="greet-input"
onChange={(e) => setName(e.currentTarget.value)}
placeholder="Enter a name..."
/>
<button type="submit">Greet</button>
</form>
<p>{greetMsg}</p>
</div>
);
}
export default App;
"#;
let expected = r#"
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, } from "@tauri-apps/api";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { convertFileSrc } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { register } from "@tauri-apps/plugin-global-shortcut";
import clipboard from "@tauri-apps/plugin-clipboard-manager";
import * as fs from "@tauri-apps/plugin-fs";
import { Store } from "@tauri-apps/plugin-store";
import Database from "@tauri-apps/plugin-sql";
import "./App.css";
import * as dialog from "@tauri-apps/plugin-dialog"
import * as cli as superCli from "@tauri-apps/plugin-cli"
const appWindow = getCurrentWebviewWindow()
function App() {
const [greetMsg, setGreetMsg] = useState("");
const [name, setName] = useState("");
async function greet() {
// Learn more about Tauri commands at https://v2.tauri.app/develop/calling-rust/#commands
setGreetMsg(await invoke("greet", { name }));
await open();
await dialog.save();
await convertFileSrc("");
const a = appWindow.label;
superCli.getMatches();
clipboard.readText();
fs.exists("");
}
return (
<div className="container">
<h1>Welcome to Tauri!</h1>
<div className="row">
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
<form
className="row"
onSubmit={(e) => {
e.preventDefault();
greet();
}}
>
<input
id="greet-input"
onChange={(e) => setName(e.currentTarget.value)}
placeholder="Enter a name..."
/>
<button type="submit">Greet</button>
</form>
<p>{greetMsg}</p>
</div>
);
}
export default App;
"#;
let mut new_plugins = Vec::new();
let mut npm_packages_to_remove = Vec::new();
let migrated = migrate_imports(
Path::new("file.js"),
input,
&mut new_plugins,
&mut npm_packages_to_remove,
)
.unwrap();
assert_eq!(migrated, expected);
assert_eq!(
new_plugins,
vec![
"dialog",
"cli",
"dialog",
"global-shortcut",
"clipboard-manager",
"fs",
"store",
"sql"
]
);
assert_eq!(
npm_packages_to_remove,
vec!["tauri-plugin-store-api", "tauri-plugin-sql-api"]
);
}
}