Add Typst Desktop app
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "typst-desktop"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "typst_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
typst = { version = "0.14.2", path = "../../typstdrive/typst/crates/typst" }
|
||||
typst-kit = { path = "../../typstdrive/typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] }
|
||||
typst-pdf = { path = "../../typstdrive/typst/crates/typst-pdf" }
|
||||
typst-render = { path = "../../typstdrive/typst/crates/typst-render" }
|
||||
typst-svg = { path = "../../typstdrive/typst/crates/typst-svg" }
|
||||
typst-html = { path = "../../typstdrive/typst/crates/typst-html" }
|
||||
typst-layout = { path = "../../typstdrive/typst/crates/typst-layout" }
|
||||
typst-assets = { version = "0.14.2", features = ["fonts"] }
|
||||
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
diffy = "0.4"
|
||||
walkdir = "2"
|
||||
ureq = { version = "2.12", features = ["json"] }
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-internal-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-close",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,254 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::db::Store;
|
||||
use crate::workspace::workspace_root;
|
||||
|
||||
pub const ASSETS_DIR: &str = ".assets";
|
||||
|
||||
const FONT_EXTENSIONS: [&str; 4] = ["ttf", "otf", "ttc", "otc"];
|
||||
const IMAGE_EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "gif", "svg", "webp"];
|
||||
|
||||
fn extension_of(name: &str) -> String {
|
||||
Path::new(name)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
pub fn is_font(name: &str) -> bool {
|
||||
FONT_EXTENSIONS.contains(&extension_of(name).as_str())
|
||||
}
|
||||
|
||||
pub fn is_image(name: &str) -> bool {
|
||||
IMAGE_EXTENSIONS.contains(&extension_of(name).as_str())
|
||||
}
|
||||
|
||||
pub fn assets_dir(app: &AppHandle, store: &Store) -> Result<PathBuf, String> {
|
||||
let dir = workspace_root(app, store)?.join(ASSETS_DIR);
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Cannot create assets folder: {}", e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn families_in(data: &[u8]) -> Vec<String> {
|
||||
let mut families = BTreeSet::new();
|
||||
for font in typst::text::Font::iter(typst::foundations::Bytes::new(data.to_vec())) {
|
||||
families.insert(font.info().family.clone());
|
||||
}
|
||||
families.into_iter().collect()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Asset {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub size: u64,
|
||||
pub font_families: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn list_assets(app: &AppHandle, store: &Store) -> Result<Vec<Asset>, String> {
|
||||
let dir = assets_dir(app, store)?;
|
||||
let mut assets = Vec::new();
|
||||
|
||||
for entry in std::fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
if !entry.path().is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let font_families = if is_font(&name) {
|
||||
std::fs::read(entry.path())
|
||||
.map(|data| families_in(&data))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
assets.push(Asset {
|
||||
kind: if is_font(&name) {
|
||||
"font"
|
||||
} else if is_image(&name) {
|
||||
"image"
|
||||
} else {
|
||||
"file"
|
||||
}
|
||||
.to_string(),
|
||||
name,
|
||||
size,
|
||||
font_families,
|
||||
});
|
||||
}
|
||||
|
||||
assets.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
Ok(assets)
|
||||
}
|
||||
|
||||
pub fn font_families(files: &HashMap<String, Vec<u8>>) -> Vec<String> {
|
||||
let mut families = BTreeSet::new();
|
||||
|
||||
for data in typst_assets::fonts() {
|
||||
for font in typst::text::Font::iter(typst::foundations::Bytes::new(data)) {
|
||||
families.insert(font.info().family.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (name, data) in files {
|
||||
if is_font(name) {
|
||||
for family in families_in(data) {
|
||||
families.insert(family);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
families.into_iter().collect()
|
||||
}
|
||||
|
||||
fn unique_destination(dir: &Path, name: &str) -> PathBuf {
|
||||
let candidate = dir.join(name);
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
let stem = Path::new(name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("file")
|
||||
.to_string();
|
||||
let extension = Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| format!(".{}", e))
|
||||
.unwrap_or_default();
|
||||
|
||||
for index in 2..1000 {
|
||||
let candidate = dir.join(format!("{}-{}{}", stem, index, extension));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
|
||||
|
||||
for entry in std::fs::read_dir(source).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let name = entry.file_name();
|
||||
let from = entry.path();
|
||||
let to = destination.join(&name);
|
||||
|
||||
if from.is_dir() {
|
||||
copy_tree(&from, &to)?;
|
||||
} else {
|
||||
std::fs::copy(&from, &to).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn import_paths(sources: &[String], destination: &Path) -> Result<Vec<String>, String> {
|
||||
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut imported = Vec::new();
|
||||
|
||||
for source in sources {
|
||||
let source_path = Path::new(source);
|
||||
let name = source_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.ok_or_else(|| format!("'{}' has no name", source))?;
|
||||
|
||||
if source_path.is_dir() {
|
||||
let target = unique_destination(destination, &name);
|
||||
copy_tree(source_path, &target)?;
|
||||
} else if source_path.is_file() {
|
||||
let target = unique_destination(destination, &name);
|
||||
std::fs::copy(source_path, &target)
|
||||
.map_err(|e| format!("Could not import '{}': {}", name, e))?;
|
||||
} else {
|
||||
return Err(format!("'{}' could not be read", source));
|
||||
}
|
||||
|
||||
imported.push(name);
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
pub fn import_files(sources: &[String], destination: &Path) -> Result<Vec<String>, String> {
|
||||
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut imported = Vec::new();
|
||||
|
||||
for source in sources {
|
||||
let source_path = Path::new(source);
|
||||
if !source_path.is_file() {
|
||||
return Err(format!("'{}' is not a file", source));
|
||||
}
|
||||
|
||||
let name = source_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.ok_or_else(|| format!("'{}' has no file name", source))?;
|
||||
|
||||
let target = unique_destination(destination, &name);
|
||||
std::fs::copy(source_path, &target)
|
||||
.map_err(|e| format!("Could not import '{}': {}", name, e))?;
|
||||
|
||||
imported.push(
|
||||
target
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or(name),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
pub fn delete_asset(app: &AppHandle, store: &Store, name: &str) -> Result<(), String> {
|
||||
if name.contains('/') || name.contains('\\') || name.contains("..") {
|
||||
return Err("Invalid asset name".to_string());
|
||||
}
|
||||
let path = assets_dir(app, store)?.join(name);
|
||||
std::fs::remove_file(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn asset_files(app: &AppHandle, store: &Store) -> HashMap<String, Vec<u8>> {
|
||||
let Ok(dir) = assets_dir(app, store) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
let mut files = HashMap::new();
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return files;
|
||||
};
|
||||
|
||||
for entry in entries.filter_map(|entry| entry.ok()) {
|
||||
if !entry.path().is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if let Ok(data) = std::fs::read(entry.path()) {
|
||||
files.insert(name, data);
|
||||
}
|
||||
}
|
||||
|
||||
files
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use typst::diag::Warned;
|
||||
use typst::layout::{Frame, FrameItem};
|
||||
use typst::WorldExt;
|
||||
use typst_html::HtmlDocument;
|
||||
use typst_layout::PagedDocument;
|
||||
use typst_pdf::{pdf, PdfOptions};
|
||||
use typst_render::{render, RenderOptions};
|
||||
use typst_svg::SvgOptions;
|
||||
|
||||
use crate::world::ProjectWorld;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct DocumentStats {
|
||||
pub pages: usize,
|
||||
pub words: usize,
|
||||
pub characters: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct Diagnostic {
|
||||
pub message: String,
|
||||
pub severity: String,
|
||||
pub line: Option<usize>,
|
||||
pub column: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CompileResult {
|
||||
pub pages: Vec<String>,
|
||||
pub stats: DocumentStats,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
fn extract_frame_text(frame: &Frame, text: &mut String) {
|
||||
for (_, item) in frame.items() {
|
||||
match item {
|
||||
FrameItem::Text(text_item) => {
|
||||
text.push_str(&text_item.text);
|
||||
text.push(' ');
|
||||
}
|
||||
FrameItem::Group(group) => extract_frame_text(&group.frame, text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_stats(document: &PagedDocument) -> DocumentStats {
|
||||
let mut text = String::new();
|
||||
for page in document.pages() {
|
||||
extract_frame_text(&page.frame, &mut text);
|
||||
}
|
||||
|
||||
DocumentStats {
|
||||
pages: document.pages().len(),
|
||||
words: text.split_whitespace().count(),
|
||||
characters: text.chars().filter(|c| !c.is_whitespace()).count(),
|
||||
}
|
||||
}
|
||||
|
||||
fn line_and_column(source: &str, offset: usize) -> (usize, usize) {
|
||||
let mut line = 1;
|
||||
let mut column = 1;
|
||||
for (index, character) in source.char_indices() {
|
||||
if index >= offset {
|
||||
break;
|
||||
}
|
||||
if character == '\n' {
|
||||
line += 1;
|
||||
column = 1;
|
||||
} else {
|
||||
column += 1;
|
||||
}
|
||||
}
|
||||
(line, column)
|
||||
}
|
||||
|
||||
fn collect_diagnostics(
|
||||
world: &ProjectWorld,
|
||||
entrypoint_source: &str,
|
||||
errors: impl IntoIterator<Item = typst::diag::SourceDiagnostic>,
|
||||
) -> Vec<Diagnostic> {
|
||||
errors
|
||||
.into_iter()
|
||||
.map(|diagnostic| {
|
||||
let (line, column) = match world.range(diagnostic.span) {
|
||||
Some(range) => {
|
||||
let (line, column) = line_and_column(entrypoint_source, range.start);
|
||||
(Some(line), Some(column))
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
Diagnostic {
|
||||
message: diagnostic.message.to_string(),
|
||||
severity: format!("{:?}", diagnostic.severity).to_lowercase(),
|
||||
line,
|
||||
column,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn entrypoint_text(files: &HashMap<String, Vec<u8>>, entrypoint: &str) -> String {
|
||||
files
|
||||
.get(entrypoint)
|
||||
.map(|bytes| String::from_utf8_lossy(bytes).to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn compile_to_svg(
|
||||
entrypoint: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<CompileResult, Vec<Diagnostic>> {
|
||||
let source_text = entrypoint_text(&files, &entrypoint);
|
||||
let world = ProjectWorld::new(entrypoint, files, false);
|
||||
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(document),
|
||||
warnings,
|
||||
} => {
|
||||
let options = SvgOptions::default();
|
||||
let pages = document
|
||||
.pages()
|
||||
.iter()
|
||||
.map(|page| typst_svg::svg(page, &options))
|
||||
.collect();
|
||||
|
||||
Ok(CompileResult {
|
||||
pages,
|
||||
stats: extract_stats(&document),
|
||||
diagnostics: collect_diagnostics(&world, &source_text, warnings),
|
||||
})
|
||||
}
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => Err(collect_diagnostics(&world, &source_text, errors)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_pdf(
|
||||
entrypoint: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<Diagnostic>> {
|
||||
let source_text = entrypoint_text(&files, &entrypoint);
|
||||
let world = ProjectWorld::new(entrypoint, files, false);
|
||||
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(document),
|
||||
warnings: _,
|
||||
} => pdf(&document, &PdfOptions::default()).map_err(|errors| {
|
||||
collect_diagnostics(&world, &source_text, errors)
|
||||
}),
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => Err(collect_diagnostics(&world, &source_text, errors)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_png(
|
||||
entrypoint: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<Diagnostic>> {
|
||||
let source_text = entrypoint_text(&files, &entrypoint);
|
||||
let world = ProjectWorld::new(entrypoint, files, false);
|
||||
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(document),
|
||||
warnings: _,
|
||||
} => {
|
||||
let options = RenderOptions {
|
||||
pixel_per_pt: 2.0,
|
||||
..RenderOptions::default()
|
||||
};
|
||||
match document.pages().first() {
|
||||
Some(page) => Ok(render(page, &options).encode_png().unwrap_or_default()),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => Err(collect_diagnostics(&world, &source_text, errors)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_html(
|
||||
entrypoint: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<Diagnostic>> {
|
||||
let source_text = entrypoint_text(&files, &entrypoint);
|
||||
let world = ProjectWorld::new(entrypoint, files, true);
|
||||
|
||||
let document = match typst::compile::<HtmlDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(document),
|
||||
warnings: _,
|
||||
} => document,
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => return Err(collect_diagnostics(&world, &source_text, errors)),
|
||||
};
|
||||
|
||||
typst_html::html(&document)
|
||||
.map(|html| html.into_bytes())
|
||||
.map_err(|errors| collect_diagnostics(&world, &source_text, errors))
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::workspace::{ProjectMeta, Settings};
|
||||
|
||||
pub struct Store {
|
||||
connection: Mutex<Connection>,
|
||||
}
|
||||
|
||||
const SCHEMA: [&str; 4] = [
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS projects (
|
||||
path TEXT PRIMARY KEY,
|
||||
entrypoint TEXT NOT NULL DEFAULT 'main.typ',
|
||||
space_id TEXT,
|
||||
last_synced_at TEXT
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS base_files (
|
||||
project_path TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
content BLOB,
|
||||
PRIMARY KEY (project_path, file_path)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS thumbnails (
|
||||
path TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
source_modified INTEGER NOT NULL
|
||||
)",
|
||||
];
|
||||
|
||||
impl Store {
|
||||
pub fn open(app: &AppHandle) -> Result<Self, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Cannot resolve data directory: {}", e))?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let connection =
|
||||
Connection::open(dir.join("typst-desktop.db")).map_err(|e| e.to_string())?;
|
||||
|
||||
connection
|
||||
.pragma_update(None, "journal_mode", "WAL")
|
||||
.map_err(|e| e.to_string())?;
|
||||
connection
|
||||
.pragma_update(None, "foreign_keys", "ON")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for statement in SCHEMA {
|
||||
connection.execute(statement, []).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(Store {
|
||||
connection: Mutex::new(connection),
|
||||
})
|
||||
}
|
||||
|
||||
fn with<T>(&self, run: impl FnOnce(&Connection) -> rusqlite::Result<T>) -> Result<T, String> {
|
||||
let connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.map_err(|_| "Local database lock poisoned".to_string())?;
|
||||
run(&connection).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> Result<Option<Settings>, String> {
|
||||
let raw: Option<String> = self.with(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'settings'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
})?;
|
||||
|
||||
match raw {
|
||||
Some(raw) => serde_json::from_str(&raw)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Stored settings are invalid: {}", e)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_settings(&self, settings: &Settings) -> Result<(), String> {
|
||||
let raw = serde_json::to_string(settings).map_err(|e| e.to_string())?;
|
||||
self.with(|connection| {
|
||||
connection.execute(
|
||||
"INSERT INTO settings (key, value) VALUES ('settings', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![raw],
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn meta(&self, project: &str) -> Result<ProjectMeta, String> {
|
||||
let row: Option<(String, Option<String>, Option<String>)> = self.with(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT entrypoint, space_id, last_synced_at FROM projects WHERE path = ?1",
|
||||
params![project],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.optional()
|
||||
})?;
|
||||
|
||||
let Some((entrypoint, space_id, last_synced_at)) = row else {
|
||||
return Ok(ProjectMeta::default());
|
||||
};
|
||||
|
||||
let base_hashes = self.with(|connection| {
|
||||
let mut statement = connection
|
||||
.prepare("SELECT file_path, hash FROM base_files WHERE project_path = ?1")?;
|
||||
let rows = statement.query_map(params![project], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for row in rows {
|
||||
let (path, hash) = row?;
|
||||
map.insert(path, hash);
|
||||
}
|
||||
Ok(map)
|
||||
})?;
|
||||
|
||||
Ok(ProjectMeta {
|
||||
entrypoint,
|
||||
space_id,
|
||||
last_synced_at,
|
||||
base_hashes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_project(&self, project: &str) -> Result<bool, String> {
|
||||
let found: Option<i64> = self.with(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT 1 FROM projects WHERE path = ?1",
|
||||
params![project],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
})?;
|
||||
Ok(found.is_some())
|
||||
}
|
||||
|
||||
pub fn save_meta(&self, project: &str, meta: &ProjectMeta) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute(
|
||||
"INSERT INTO projects (path, entrypoint, space_id, last_synced_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
entrypoint = excluded.entrypoint,
|
||||
space_id = excluded.space_id,
|
||||
last_synced_at = excluded.last_synced_at",
|
||||
params![
|
||||
project,
|
||||
meta.entrypoint,
|
||||
meta.space_id,
|
||||
meta.last_synced_at
|
||||
],
|
||||
)?;
|
||||
|
||||
let mut keep: Vec<String> = Vec::new();
|
||||
for (file, hash) in &meta.base_hashes {
|
||||
connection.execute(
|
||||
"INSERT INTO base_files (project_path, file_path, hash)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(project_path, file_path) DO UPDATE SET hash = excluded.hash",
|
||||
params![project, file, hash],
|
||||
)?;
|
||||
keep.push(file.clone());
|
||||
}
|
||||
|
||||
let mut statement = connection
|
||||
.prepare("SELECT file_path FROM base_files WHERE project_path = ?1")?;
|
||||
let existing: Vec<String> = statement
|
||||
.query_map(params![project], |row| row.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||
|
||||
for file in existing {
|
||||
if !keep.contains(&file) {
|
||||
connection.execute(
|
||||
"DELETE FROM base_files WHERE project_path = ?1 AND file_path = ?2",
|
||||
params![project, file],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn forget_project(&self, project: &str) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute(
|
||||
"DELETE FROM base_files WHERE project_path = ?1",
|
||||
params![project],
|
||||
)?;
|
||||
connection.execute("DELETE FROM projects WHERE path = ?1", params![project])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn rename_project(&self, from: &str, to: &str) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute(
|
||||
"UPDATE projects SET path = ?2 WHERE path = ?1",
|
||||
params![from, to],
|
||||
)?;
|
||||
connection.execute(
|
||||
"UPDATE base_files SET project_path = ?2 WHERE project_path = ?1",
|
||||
params![from, to],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn base_snapshot(&self, project: &str, file: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
self.with(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT content FROM base_files WHERE project_path = ?1 AND file_path = ?2",
|
||||
params![project, file],
|
||||
|row| row.get::<_, Option<Vec<u8>>>(0),
|
||||
)
|
||||
.optional()
|
||||
.map(|value| value.flatten())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_base_snapshot(
|
||||
&self,
|
||||
project: &str,
|
||||
file: &str,
|
||||
hash: &str,
|
||||
content: &[u8],
|
||||
) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute(
|
||||
"INSERT INTO base_files (project_path, file_path, hash, content)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(project_path, file_path) DO UPDATE SET
|
||||
hash = excluded.hash,
|
||||
content = excluded.content",
|
||||
params![project, file, hash, content],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn thumbnail(&self, path: &str, modified: i64) -> Result<Option<(String, String)>, String> {
|
||||
self.with(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT kind, data FROM thumbnails
|
||||
WHERE path = ?1 AND source_modified >= ?2",
|
||||
params![path, modified],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_thumbnail(
|
||||
&self,
|
||||
path: &str,
|
||||
kind: &str,
|
||||
data: &str,
|
||||
modified: i64,
|
||||
) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute(
|
||||
"INSERT INTO thumbnails (path, kind, data, source_modified)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
kind = excluded.kind,
|
||||
data = excluded.data,
|
||||
source_modified = excluded.source_modified",
|
||||
params![path, kind, data, modified],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_thumbnails(&self) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute("DELETE FROM thumbnails", [])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
mod assets;
|
||||
mod compiler;
|
||||
mod db;
|
||||
mod lsp;
|
||||
mod sync;
|
||||
mod thumbnails;
|
||||
mod workspace;
|
||||
mod world;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
use assets::Asset;
|
||||
use db::Store;
|
||||
use compiler::{CompileResult, Diagnostic};
|
||||
use lsp::{LspHandle, LspState};
|
||||
use sync::{Account, SpaceSummary, SyncReport};
|
||||
use workspace::{
|
||||
browse, is_project_dir, is_text_file, list_files, load_settings, project_file_path,
|
||||
read_target_files, resolve_target, save_settings, workspace_path, BrowseEntry,
|
||||
FileEntry, ProjectMeta, Settings, NEW_PROJECT_MAIN,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CompileFailure {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
fn failure(message: String) -> CompileFailure {
|
||||
CompileFailure {
|
||||
diagnostics: vec![Diagnostic {
|
||||
message,
|
||||
severity: "error".to_string(),
|
||||
line: None,
|
||||
column: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn cloud_credentials(app: &AppHandle, store: &Store) -> Result<(String, String), String> {
|
||||
let settings = load_settings(app, store)?;
|
||||
let token = settings.device_token.ok_or("Not signed in to TypstDrive")?;
|
||||
Ok((settings.server_url, token))
|
||||
}
|
||||
|
||||
fn load_project(
|
||||
app: &AppHandle,
|
||||
store: &Store,
|
||||
project: &str,
|
||||
) -> Result<(PathBuf, ProjectMeta), String> {
|
||||
let dir = workspace_path(app, store, project)?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("Project '{}' not found", project));
|
||||
}
|
||||
Ok((dir, store.meta(project)?))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_settings(app: AppHandle, store: State<'_, Store>) -> Result<Settings, String> {
|
||||
load_settings(&app, &store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_settings(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
workspace_root: Option<String>,
|
||||
server_url: Option<String>,
|
||||
) -> Result<Settings, String> {
|
||||
let mut settings = load_settings(&app, &store)?;
|
||||
if let Some(root) = workspace_root {
|
||||
if root.trim().is_empty() {
|
||||
return Err("Workspace folder cannot be empty".to_string());
|
||||
}
|
||||
settings.workspace_root = root;
|
||||
}
|
||||
if let Some(url) = server_url {
|
||||
settings.server_url = url.trim_end_matches('/').to_string();
|
||||
}
|
||||
save_settings(&store, &settings)?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn browse_workspace(app: AppHandle, store: State<'_, Store>, path: String) -> Result<Vec<BrowseEntry>, String> {
|
||||
browse(&app, &store, &path)
|
||||
}
|
||||
|
||||
fn parent_of(path: &str) -> String {
|
||||
match path.rsplit_once('/') {
|
||||
Some((parent, _)) => parent.to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn join_path(parent: &str, name: &str) -> String {
|
||||
if parent.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", parent.trim_end_matches('/'), name)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_folder_entry(app: AppHandle, store: State<'_, Store>, parent: String, name: String) -> Result<String, String> {
|
||||
let path = join_path(&parent, name.trim());
|
||||
let full = workspace_path(&app, &store, &path)?;
|
||||
if full.exists() {
|
||||
return Err(format!("'{}' already exists", name));
|
||||
}
|
||||
std::fs::create_dir_all(&full).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_document_entry(app: AppHandle, store: State<'_, Store>, parent: String, name: String) -> Result<String, String> {
|
||||
let mut name = name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err("Document name cannot be empty".to_string());
|
||||
}
|
||||
if !name.to_lowercase().ends_with(".typ") {
|
||||
name.push_str(".typ");
|
||||
}
|
||||
|
||||
let path = join_path(&parent, &name);
|
||||
let full = workspace_path(&app, &store, &path)?;
|
||||
if full.exists() {
|
||||
return Err(format!("'{}' already exists", name));
|
||||
}
|
||||
if let Some(dir) = full.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let title = name.trim_end_matches(".typ").trim_end_matches(".TYP");
|
||||
std::fs::write(&full, format!("= {}\n\nStart writing here.\n", title))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_project_entry(app: AppHandle, store: State<'_, Store>, parent: String, name: String) -> Result<String, String> {
|
||||
let name = name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err("Project name cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let path = join_path(&parent, &name);
|
||||
let full = workspace_path(&app, &store, &path)?;
|
||||
if full.exists() {
|
||||
return Err(format!("'{}' already exists", name));
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&full).map_err(|e| e.to_string())?;
|
||||
std::fs::write(full.join("main.typ"), NEW_PROJECT_MAIN).map_err(|e| e.to_string())?;
|
||||
std::fs::write(full.join("typst.toml"), workspace::manifest_for(&name))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
store.save_meta(
|
||||
&path,
|
||||
&ProjectMeta {
|
||||
entrypoint: "main.typ".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn rename_entry(app: AppHandle, store: State<'_, Store>, path: String, new_name: String) -> Result<String, String> {
|
||||
let new_name = new_name.trim();
|
||||
if new_name.is_empty() || new_name.contains('/') || new_name.contains('\\') {
|
||||
return Err("Invalid name".to_string());
|
||||
}
|
||||
|
||||
let from = workspace_path(&app, &store, &path)?;
|
||||
let target_path = join_path(&parent_of(&path), new_name);
|
||||
let to = workspace_path(&app, &store, &target_path)?;
|
||||
|
||||
if to.exists() {
|
||||
return Err(format!("'{}' already exists", new_name));
|
||||
}
|
||||
std::fs::rename(&from, &to).map_err(|e| e.to_string())?;
|
||||
store.rename_project(&path, &target_path)?;
|
||||
Ok(target_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_entry(app: AppHandle, store: State<'_, Store>, path: String) -> Result<(), String> {
|
||||
let full = workspace_path(&app, &store, &path)?;
|
||||
if full.is_dir() {
|
||||
std::fs::remove_dir_all(&full).map_err(|e| e.to_string())?;
|
||||
store.forget_project(&path)?;
|
||||
Ok(())
|
||||
} else {
|
||||
std::fs::remove_file(&full).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn upload_entry(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
parent: String,
|
||||
name: String,
|
||||
base64_content: String,
|
||||
) -> Result<String, String> {
|
||||
let path = join_path(&parent, &name);
|
||||
let full = workspace_path(&app, &store, &path)?;
|
||||
let bytes = BASE64
|
||||
.decode(base64_content.as_bytes())
|
||||
.map_err(|e| format!("Invalid file data: {}", e))?;
|
||||
if let Some(dir) = full.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&full, bytes).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FilePayload {
|
||||
pub path: String,
|
||||
pub is_text: bool,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TargetInfo {
|
||||
pub path: String,
|
||||
pub entrypoint: String,
|
||||
pub standalone: bool,
|
||||
pub is_project: bool,
|
||||
pub space_id: Option<String>,
|
||||
pub files: Vec<FileEntry>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn target_info(app: AppHandle, store: State<'_, Store>, path: String) -> Result<TargetInfo, String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
|
||||
if target.standalone {
|
||||
let size = std::fs::metadata(target.root.join(&target.entrypoint))
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
return Ok(TargetInfo {
|
||||
path,
|
||||
entrypoint: target.entrypoint.clone(),
|
||||
standalone: true,
|
||||
is_project: false,
|
||||
space_id: None,
|
||||
files: vec![FileEntry {
|
||||
path: target.entrypoint.clone(),
|
||||
name: target.entrypoint,
|
||||
is_text: true,
|
||||
size,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
let meta = store.meta(&path)?;
|
||||
Ok(TargetInfo {
|
||||
path,
|
||||
entrypoint: target.entrypoint,
|
||||
standalone: false,
|
||||
is_project: is_project_dir(&target.root),
|
||||
space_id: meta.space_id,
|
||||
files: list_files(&target.root)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_target_file(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
file: String,
|
||||
) -> Result<FilePayload, String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
let full = project_file_path(&target.root, &file)?;
|
||||
let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
|
||||
|
||||
let is_text = is_text_file(&file);
|
||||
Ok(FilePayload {
|
||||
path: file,
|
||||
is_text,
|
||||
content: if is_text {
|
||||
String::from_utf8_lossy(&bytes).to_string()
|
||||
} else {
|
||||
BASE64.encode(&bytes)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_target_file(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
file: String,
|
||||
content: String,
|
||||
) -> Result<(), String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
let full = project_file_path(&target.root, &file)?;
|
||||
if let Some(dir) = full.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&full, content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_target_entrypoint(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
entrypoint: String,
|
||||
) -> Result<(), String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
if target.standalone {
|
||||
return Err("A standalone document is its own entrypoint".to_string());
|
||||
}
|
||||
let mut meta = store.meta(&path)?;
|
||||
meta.entrypoint = entrypoint;
|
||||
store.save_meta(&path, &meta)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn compile_target(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
overrides: Option<std::collections::HashMap<String, String>>,
|
||||
) -> Result<CompileResult, CompileFailure> {
|
||||
let target = resolve_target(&app, &store, &path).map_err(failure)?;
|
||||
let mut files = read_target_files(&app, &store, &target).map_err(failure)?;
|
||||
|
||||
for (file, content) in overrides.unwrap_or_default() {
|
||||
files.insert(file, content.into_bytes());
|
||||
}
|
||||
|
||||
compiler::compile_to_svg(target.entrypoint, files)
|
||||
.map_err(|diagnostics| CompileFailure { diagnostics })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_target(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
format: String,
|
||||
destination: String,
|
||||
) -> Result<String, String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
let files = read_target_files(&app, &store, &target)?;
|
||||
|
||||
let bytes = match format.as_str() {
|
||||
"pdf" => compiler::export_pdf(target.entrypoint, files),
|
||||
"png" => compiler::export_png(target.entrypoint, files),
|
||||
"html" => compiler::export_html(target.entrypoint, files),
|
||||
other => return Err(format!("Unsupported export format '{}'", other)),
|
||||
}
|
||||
.map_err(|diagnostics| {
|
||||
diagnostics
|
||||
.into_iter()
|
||||
.map(|d| d.message)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
})?;
|
||||
|
||||
std::fs::write(&destination, bytes).map_err(|e| e.to_string())?;
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn thumbnail(app: AppHandle, store: State<'_, Store>, path: String) -> Result<thumbnails::Thumbnail, String> {
|
||||
thumbnails::thumbnail(&app, &store, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_image(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
) -> Result<thumbnails::ImageData, String> {
|
||||
thumbnails::read_image(&app, &store, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clear_thumbnails(store: State<'_, Store>) -> Result<(), String> {
|
||||
store.clear_thumbnails()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_assets(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Asset>, String> {
|
||||
assets::list_assets(&app, &store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_font_families(app: AppHandle, store: State<'_, Store>, path: Option<String>) -> Result<Vec<String>, String> {
|
||||
let files = match path {
|
||||
Some(path) if !path.is_empty() => {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
read_target_files(&app, &store, &target)?
|
||||
}
|
||||
_ => assets::asset_files(&app, &store),
|
||||
};
|
||||
|
||||
Ok(assets::font_families(&files))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn import_assets(app: AppHandle, store: State<'_, Store>, sources: Vec<String>) -> Result<Vec<String>, String> {
|
||||
let destination = assets::assets_dir(&app, &store)?;
|
||||
assets::import_files(&sources, &destination)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_asset(app: AppHandle, store: State<'_, Store>, name: String) -> Result<(), String> {
|
||||
assets::delete_asset(&app, &store, &name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn import_into_target(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
sources: Vec<String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
assets::import_paths(&sources, &target.root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn import_into_folder(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
parent: String,
|
||||
sources: Vec<String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let destination = workspace_path(&app, &store, &parent)?;
|
||||
assets::import_paths(&sources, &destination)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn lsp_start(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
state: State<'_, LspState>,
|
||||
path: String,
|
||||
) -> Result<LspHandle, String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
state.start(&app, &target.root, &target.entrypoint)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn lsp_send(state: State<'_, LspState>, message: String) -> Result<(), String> {
|
||||
state.send(&message)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn lsp_stop(state: State<'_, LspState>) {
|
||||
state.stop();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn lsp_running(state: State<'_, LspState>) -> bool {
|
||||
state.is_running()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_login(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
server_url: String,
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result<Account, String> {
|
||||
let server_url = server_url.trim_end_matches('/').to_string();
|
||||
let device_name = format!("Typst Desktop ({})", std::env::consts::OS);
|
||||
let response = sync::login(&server_url, &email, &password, &device_name)?;
|
||||
|
||||
let mut settings = load_settings(&app, &store)?;
|
||||
settings.server_url = server_url;
|
||||
settings.device_token = Some(response.token);
|
||||
settings.account_email = Some(response.email.clone());
|
||||
settings.account_username = Some(response.username.clone());
|
||||
save_settings(&store, &settings)?;
|
||||
|
||||
Ok(Account {
|
||||
user_id: response.user_id,
|
||||
username: response.username,
|
||||
email: response.email,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_logout(app: AppHandle, store: State<'_, Store>) -> Result<(), String> {
|
||||
let mut settings = load_settings(&app, &store)?;
|
||||
if let Some(token) = &settings.device_token {
|
||||
let _ = sync::logout(&settings.server_url, token);
|
||||
}
|
||||
|
||||
settings.device_token = None;
|
||||
settings.account_email = None;
|
||||
settings.account_username = None;
|
||||
save_settings(&store, &settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_account(app: AppHandle, store: State<'_, Store>) -> Result<Option<Account>, String> {
|
||||
let settings = load_settings(&app, &store)?;
|
||||
let Some(token) = settings.device_token else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(sync::me(&settings.server_url, &token).ok())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_list_spaces(app: AppHandle, store: State<'_, Store>) -> Result<Vec<SpaceSummary>, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
sync::list_spaces(&server_url, &token)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_create_space(app: AppHandle, store: State<'_, Store>, name: String) -> Result<SpaceSummary, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
sync::create_space(&server_url, &token, name.trim())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_delete_space(app: AppHandle, store: State<'_, Store>, space_id: String) -> Result<(), String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
sync::delete_space(&server_url, &token, &space_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_clone_space(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
space_id: String,
|
||||
project_name: String,
|
||||
) -> Result<SyncReport, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let project = project_name.trim().to_string();
|
||||
let dir = workspace_path(&app, &store, &project)?;
|
||||
if dir.exists() {
|
||||
return Err(format!("A project named '{}' already exists", project_name));
|
||||
}
|
||||
sync::clone_space(&server_url, &token, &store, &project, &dir, &space_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_link_project(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
project: String,
|
||||
space_id: Option<String>,
|
||||
) -> Result<SyncReport, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let (dir, mut meta) = load_project(&app, &store, &project)?;
|
||||
|
||||
let space_id = match space_id {
|
||||
Some(id) if !id.trim().is_empty() => id,
|
||||
_ => sync::create_space(&server_url, &token, &project)?.id,
|
||||
};
|
||||
|
||||
meta.space_id = Some(space_id);
|
||||
meta.base_hashes.clear();
|
||||
store.save_meta(&project, &meta)?;
|
||||
|
||||
sync::push_project(&server_url, &token, &store, &project, &dir, &mut meta)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_unlink_project(app: AppHandle, store: State<'_, Store>, project: String) -> Result<(), String> {
|
||||
let (dir, mut meta) = load_project(&app, &store, &project)?;
|
||||
meta.space_id = None;
|
||||
meta.base_hashes.clear();
|
||||
meta.last_synced_at = None;
|
||||
let _ = dir;
|
||||
store.forget_project(&project)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_push(app: AppHandle, store: State<'_, Store>, project: String) -> Result<SyncReport, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let (dir, mut meta) = load_project(&app, &store, &project)?;
|
||||
sync::push_project(&server_url, &token, &store, &project, &dir, &mut meta)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_pull(app: AppHandle, store: State<'_, Store>, project: String) -> Result<SyncReport, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let (dir, mut meta) = load_project(&app, &store, &project)?;
|
||||
sync::pull_project(&server_url, &token, &store, &project, &dir, &mut meta)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_sync(app: AppHandle, store: State<'_, Store>, project: String) -> Result<SyncReport, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let (dir, mut meta) = load_project(&app, &store, &project)?;
|
||||
|
||||
let mut report = sync::pull_project(&server_url, &token, &store, &project, &dir, &mut meta)?;
|
||||
if !report.conflicts.is_empty() {
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
let pushed = sync::push_project(&server_url, &token, &store, &project, &dir, &mut meta)?;
|
||||
report.pushed = pushed.pushed;
|
||||
report.deleted_remote = pushed.deleted_remote;
|
||||
report.conflicts = pushed.conflicts;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResolutionRequest {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub server_hash: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_resolve_conflicts(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
project: String,
|
||||
resolutions: Vec<ResolutionRequest>,
|
||||
) -> Result<SyncReport, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let (dir, mut meta) = load_project(&app, &store, &project)?;
|
||||
|
||||
for resolution in &resolutions {
|
||||
sync::resolve_conflict(
|
||||
&store,
|
||||
&project,
|
||||
&dir,
|
||||
&mut meta,
|
||||
&resolution.path,
|
||||
&resolution.content,
|
||||
&resolution.server_hash,
|
||||
)?;
|
||||
}
|
||||
|
||||
sync::push_project(&server_url, &token, &store, &project, &dir, &mut meta)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
let store = Store::open(&app.handle())?;
|
||||
app.manage(store);
|
||||
Ok(())
|
||||
})
|
||||
.manage(LspState::default())
|
||||
.on_window_event(|window, event| {
|
||||
if matches!(event, tauri::WindowEvent::Destroyed) {
|
||||
if let Some(state) = window.app_handle().try_state::<LspState>() {
|
||||
state.stop();
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_settings,
|
||||
update_settings,
|
||||
browse_workspace,
|
||||
create_folder_entry,
|
||||
create_document_entry,
|
||||
create_project_entry,
|
||||
rename_entry,
|
||||
delete_entry,
|
||||
upload_entry,
|
||||
target_info,
|
||||
read_target_file,
|
||||
write_target_file,
|
||||
set_target_entrypoint,
|
||||
compile_target,
|
||||
export_target,
|
||||
thumbnail,
|
||||
read_image,
|
||||
clear_thumbnails,
|
||||
list_assets,
|
||||
list_font_families,
|
||||
import_assets,
|
||||
delete_asset,
|
||||
import_into_target,
|
||||
import_into_folder,
|
||||
lsp_start,
|
||||
lsp_send,
|
||||
lsp_stop,
|
||||
lsp_running,
|
||||
cloud_login,
|
||||
cloud_logout,
|
||||
cloud_account,
|
||||
cloud_list_spaces,
|
||||
cloud_create_space,
|
||||
cloud_delete_space,
|
||||
cloud_clone_space,
|
||||
cloud_link_project,
|
||||
cloud_unlink_project,
|
||||
cloud_push,
|
||||
cloud_pull,
|
||||
cloud_sync,
|
||||
cloud_resolve_conflicts,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use serde::Serialize;
|
||||
use std::io::{BufReader, Read, Write};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
pub const MESSAGE_EVENT: &str = "lsp://message";
|
||||
pub const CLOSED_EVENT: &str = "lsp://closed";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LspState {
|
||||
inner: Mutex<Option<Session>>,
|
||||
}
|
||||
|
||||
struct Session {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct LspHandle {
|
||||
pub root_uri: String,
|
||||
pub document_uri: String,
|
||||
}
|
||||
|
||||
fn file_uri(path: &Path) -> String {
|
||||
let text = path.to_string_lossy().replace('\\', "/");
|
||||
if text.starts_with('/') {
|
||||
format!("file://{}", text)
|
||||
} else {
|
||||
format!("file:///{}", text)
|
||||
}
|
||||
}
|
||||
|
||||
impl LspState {
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.inner.lock().map(|slot| slot.is_some()).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
root: &Path,
|
||||
entrypoint: &str,
|
||||
) -> Result<LspHandle, String> {
|
||||
self.stop();
|
||||
|
||||
let mut child = Command::new("tinymist")
|
||||
.arg("lsp")
|
||||
.current_dir(root)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Could not start the Typst language server (tinymist): {}. \
|
||||
Install tinymist and make sure it is on your PATH.",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let stdin = child.stdin.take().ok_or("Language server has no stdin")?;
|
||||
let stdout = child.stdout.take().ok_or("Language server has no stdout")?;
|
||||
|
||||
let emitter = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
loop {
|
||||
match read_message(&mut reader) {
|
||||
Some(message) => {
|
||||
let _ = emitter.emit(MESSAGE_EVENT, message);
|
||||
}
|
||||
None => {
|
||||
let _ = emitter.emit(CLOSED_EVENT, ());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let handle = LspHandle {
|
||||
root_uri: file_uri(root),
|
||||
document_uri: file_uri(&root.join(entrypoint)),
|
||||
};
|
||||
|
||||
let mut slot = self.inner.lock().map_err(|_| "Language server lock poisoned")?;
|
||||
*slot = Some(Session { child, stdin });
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
pub fn send(&self, message: &str) -> Result<(), String> {
|
||||
let mut slot = self.inner.lock().map_err(|_| "Language server lock poisoned")?;
|
||||
let session = slot.as_mut().ok_or("Language server is not running")?;
|
||||
|
||||
session
|
||||
.stdin
|
||||
.write_all(format!("Content-Length: {}\r\n\r\n", message.len()).as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
session
|
||||
.stdin
|
||||
.write_all(message.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
session.stdin.flush().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
if let Ok(mut slot) = self.inner.lock() {
|
||||
if let Some(mut session) = slot.take() {
|
||||
let _ = session.child.kill();
|
||||
let _ = session.child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_message(reader: &mut BufReader<std::process::ChildStdout>) -> Option<String> {
|
||||
let mut header = String::new();
|
||||
|
||||
loop {
|
||||
let mut byte = [0u8; 1];
|
||||
if reader.read_exact(&mut byte).is_err() {
|
||||
return None;
|
||||
}
|
||||
header.push(byte[0] as char);
|
||||
if header.ends_with("\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
if header.len() > 8192 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut content_length = 0usize;
|
||||
for line in header.split("\r\n") {
|
||||
if let Some(value) = line.strip_prefix("Content-Length: ") {
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
if content_length == 0 {
|
||||
return Some(String::new());
|
||||
}
|
||||
|
||||
let mut body = vec![0u8; content_length];
|
||||
if reader.read_exact(&mut body).is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
String::from_utf8(body).ok()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
typst_desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::workspace::{
|
||||
collect_files, content_hash, is_text_file, project_file_path, ProjectMeta,
|
||||
};
|
||||
use crate::db::Store;
|
||||
|
||||
const REQUEST_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
fn agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
|
||||
.build()
|
||||
}
|
||||
|
||||
fn endpoint(server_url: &str, path: &str) -> String {
|
||||
format!("{}/api/desktop{}", server_url.trim_end_matches('/'), path)
|
||||
}
|
||||
|
||||
fn describe(error: ureq::Error) -> String {
|
||||
match error {
|
||||
ureq::Error::Status(code, response) => {
|
||||
let body = response.into_string().unwrap_or_default();
|
||||
if body.is_empty() {
|
||||
format!("Server returned {}", code)
|
||||
} else {
|
||||
body
|
||||
}
|
||||
}
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct Account {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
pub fn login(
|
||||
server_url: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
device_name: &str,
|
||||
) -> Result<LoginResponse, String> {
|
||||
agent()
|
||||
.post(&endpoint(server_url, "/auth/login"))
|
||||
.send_json(ureq::json!({
|
||||
"email": email,
|
||||
"password": password,
|
||||
"device_name": device_name,
|
||||
}))
|
||||
.map_err(describe)?
|
||||
.into_json::<LoginResponse>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn logout(server_url: &str, token: &str) -> Result<(), String> {
|
||||
agent()
|
||||
.post(&endpoint(server_url, "/auth/logout"))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn me(server_url: &str, token: &str) -> Result<Account, String> {
|
||||
agent()
|
||||
.get(&endpoint(server_url, "/auth/me"))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?
|
||||
.into_json::<Account>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct SpaceSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub entrypoint: String,
|
||||
pub role: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub fn list_spaces(server_url: &str, token: &str) -> Result<Vec<SpaceSummary>, String> {
|
||||
agent()
|
||||
.get(&endpoint(server_url, "/spaces"))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?
|
||||
.into_json::<Vec<SpaceSummary>>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn create_space(server_url: &str, token: &str, name: &str) -> Result<SpaceSummary, String> {
|
||||
agent()
|
||||
.post(&endpoint(server_url, "/spaces"))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.send_json(ureq::json!({ "name": name }))
|
||||
.map_err(describe)?
|
||||
.into_json::<SpaceSummary>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn delete_space(server_url: &str, token: &str, space_id: &str) -> Result<(), String> {
|
||||
agent()
|
||||
.delete(&endpoint(server_url, &format!("/spaces/{}", space_id)))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ManifestEntry {
|
||||
pub path: String,
|
||||
pub kind: String,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SpaceManifest {
|
||||
pub space_id: String,
|
||||
pub name: String,
|
||||
pub entrypoint: String,
|
||||
pub files: Vec<ManifestEntry>,
|
||||
}
|
||||
|
||||
pub fn get_manifest(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
space_id: &str,
|
||||
) -> Result<SpaceManifest, String> {
|
||||
agent()
|
||||
.get(&endpoint(
|
||||
server_url,
|
||||
&format!("/spaces/{}/manifest", space_id),
|
||||
))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?
|
||||
.into_json::<SpaceManifest>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FileContent {
|
||||
pub path: String,
|
||||
pub kind: String,
|
||||
pub hash: String,
|
||||
pub encoding: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl FileContent {
|
||||
pub fn bytes(&self) -> Result<Vec<u8>, String> {
|
||||
if self.encoding == "base64" {
|
||||
BASE64
|
||||
.decode(self.content.as_bytes())
|
||||
.map_err(|e| format!("Invalid base64 from server: {}", e))
|
||||
} else {
|
||||
Ok(self.content.clone().into_bytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pull_file(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
space_id: &str,
|
||||
path: &str,
|
||||
) -> Result<FileContent, String> {
|
||||
agent()
|
||||
.get(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
|
||||
.query("path", path)
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?
|
||||
.into_json::<FileContent>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConflictBody {
|
||||
server_hash: String,
|
||||
encoding: String,
|
||||
server_content: String,
|
||||
}
|
||||
|
||||
pub enum PushResult {
|
||||
Applied,
|
||||
Conflict { server_hash: String, server_text: String },
|
||||
}
|
||||
|
||||
pub fn push_file(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
space_id: &str,
|
||||
path: &str,
|
||||
bytes: &[u8],
|
||||
base_hash: Option<&str>,
|
||||
) -> Result<PushResult, String> {
|
||||
let (encoding, content) = if is_text_file(path) {
|
||||
("utf8", String::from_utf8_lossy(bytes).to_string())
|
||||
} else {
|
||||
("base64", BASE64.encode(bytes))
|
||||
};
|
||||
|
||||
let response = agent()
|
||||
.put(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.send_json(ureq::json!({
|
||||
"path": path,
|
||||
"content": content,
|
||||
"encoding": encoding,
|
||||
"base_hash": base_hash,
|
||||
}));
|
||||
|
||||
match response {
|
||||
Ok(_) => Ok(PushResult::Applied),
|
||||
Err(ureq::Error::Status(409, body)) => {
|
||||
let conflict = body
|
||||
.into_json::<ConflictBody>()
|
||||
.map_err(|e| format!("Malformed conflict response: {}", e))?;
|
||||
let server_text = if conflict.encoding == "base64" {
|
||||
String::new()
|
||||
} else {
|
||||
conflict.server_content
|
||||
};
|
||||
Ok(PushResult::Conflict {
|
||||
server_hash: conflict.server_hash,
|
||||
server_text,
|
||||
})
|
||||
}
|
||||
Err(other) => Err(describe(other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_remote_file(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
space_id: &str,
|
||||
path: &str,
|
||||
) -> Result<(), String> {
|
||||
let response = agent()
|
||||
.delete(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
|
||||
.query("path", path)
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call();
|
||||
|
||||
match response {
|
||||
Ok(_) => Ok(()),
|
||||
Err(ureq::Error::Status(404, _)) => Ok(()),
|
||||
Err(other) => Err(describe(other)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct Conflict {
|
||||
pub path: String,
|
||||
pub local_text: String,
|
||||
pub remote_text: String,
|
||||
pub merged_text: String,
|
||||
pub server_hash: String,
|
||||
pub auto_merged: bool,
|
||||
pub binary: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
pub struct SyncReport {
|
||||
pub pushed: Vec<String>,
|
||||
pub pulled: Vec<String>,
|
||||
pub deleted_local: Vec<String>,
|
||||
pub deleted_remote: Vec<String>,
|
||||
pub merged: Vec<String>,
|
||||
pub conflicts: Vec<Conflict>,
|
||||
}
|
||||
|
||||
fn read_local(project_dir: &Path, relative: &str) -> Result<Vec<u8>, String> {
|
||||
let full = project_file_path(project_dir, relative)?;
|
||||
std::fs::read(&full).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn write_local(project_dir: &Path, relative: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let full = project_file_path(project_dir, relative)?;
|
||||
if let Some(parent) = full.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&full, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn pull_project(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
store: &Store,
|
||||
project: &str,
|
||||
project_dir: &Path,
|
||||
meta: &mut ProjectMeta,
|
||||
) -> Result<SyncReport, String> {
|
||||
let space_id = meta
|
||||
.space_id
|
||||
.clone()
|
||||
.ok_or("Project is not linked to a cloud space")?;
|
||||
|
||||
let manifest = get_manifest(server_url, token, &space_id)?;
|
||||
let mut report = SyncReport::default();
|
||||
|
||||
let local_files: HashSet<String> = collect_files(project_dir)?.into_iter().collect();
|
||||
let mut remote_paths = HashSet::new();
|
||||
|
||||
for entry in &manifest.files {
|
||||
remote_paths.insert(entry.path.clone());
|
||||
|
||||
let base = meta.base_hashes.get(&entry.path).cloned();
|
||||
let local_exists = local_files.contains(&entry.path);
|
||||
|
||||
if !local_exists {
|
||||
if base.is_some() {
|
||||
continue;
|
||||
}
|
||||
let remote = pull_file(server_url, token, &space_id, &entry.path)?;
|
||||
write_local(project_dir, &entry.path, &remote.bytes()?)?;
|
||||
meta.base_hashes.insert(entry.path.clone(), remote.hash);
|
||||
report.pulled.push(entry.path.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
let local_bytes = read_local(project_dir, &entry.path)?;
|
||||
let local_hash = content_hash(&local_bytes);
|
||||
|
||||
if local_hash == entry.hash {
|
||||
meta.base_hashes.insert(entry.path.clone(), entry.hash.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
if base.as_deref() == Some(entry.hash.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let remote = pull_file(server_url, token, &space_id, &entry.path)?;
|
||||
let remote_bytes = remote.bytes()?;
|
||||
|
||||
if base.as_deref() == Some(local_hash.as_str()) {
|
||||
write_local(project_dir, &entry.path, &remote_bytes)?;
|
||||
meta.base_hashes.insert(entry.path.clone(), remote.hash);
|
||||
report.pulled.push(entry.path.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
if !is_text_file(&entry.path) || remote.kind == "binary" {
|
||||
report.conflicts.push(Conflict {
|
||||
path: entry.path.clone(),
|
||||
local_text: String::new(),
|
||||
remote_text: String::new(),
|
||||
merged_text: String::new(),
|
||||
server_hash: remote.hash,
|
||||
auto_merged: false,
|
||||
binary: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let local_text = String::from_utf8_lossy(&local_bytes).to_string();
|
||||
let remote_text = String::from_utf8_lossy(&remote_bytes).to_string();
|
||||
let base_text = read_base_snapshot(store, project, &entry.path);
|
||||
|
||||
match diffy::merge(&base_text, &local_text, &remote_text) {
|
||||
Ok(merged) => {
|
||||
write_local(project_dir, &entry.path, merged.as_bytes())?;
|
||||
meta.base_hashes
|
||||
.insert(entry.path.clone(), content_hash(merged.as_bytes()));
|
||||
report.merged.push(entry.path.clone());
|
||||
}
|
||||
Err(conflicted) => {
|
||||
report.conflicts.push(Conflict {
|
||||
path: entry.path.clone(),
|
||||
local_text,
|
||||
remote_text,
|
||||
merged_text: conflicted,
|
||||
server_hash: remote.hash,
|
||||
auto_merged: false,
|
||||
binary: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let vanished: Vec<String> = meta
|
||||
.base_hashes
|
||||
.keys()
|
||||
.filter(|path| !remote_paths.contains(*path) && local_files.contains(*path))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for path in vanished {
|
||||
let local_bytes = read_local(project_dir, &path)?;
|
||||
let base = meta.base_hashes.get(&path).cloned().unwrap_or_default();
|
||||
if content_hash(&local_bytes) == base {
|
||||
let full = project_file_path(project_dir, &path)?;
|
||||
let _ = std::fs::remove_file(full);
|
||||
meta.base_hashes.remove(&path);
|
||||
report.deleted_local.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
meta.entrypoint = manifest.entrypoint;
|
||||
meta.last_synced_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
store.save_meta(project, meta)?;
|
||||
save_base_snapshots(store, project, project_dir, meta)?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
pub fn push_project(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
store: &Store,
|
||||
project: &str,
|
||||
project_dir: &Path,
|
||||
meta: &mut ProjectMeta,
|
||||
) -> Result<SyncReport, String> {
|
||||
let space_id = meta
|
||||
.space_id
|
||||
.clone()
|
||||
.ok_or("Project is not linked to a cloud space")?;
|
||||
|
||||
let mut report = SyncReport::default();
|
||||
let local_files = collect_files(project_dir)?;
|
||||
let local_set: HashSet<String> = local_files.iter().cloned().collect();
|
||||
|
||||
for path in &local_files {
|
||||
let bytes = read_local(project_dir, path)?;
|
||||
let hash = content_hash(&bytes);
|
||||
let base = meta.base_hashes.get(path).cloned();
|
||||
|
||||
if base.as_deref() == Some(hash.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match push_file(server_url, token, &space_id, path, &bytes, base.as_deref())? {
|
||||
PushResult::Applied => {
|
||||
meta.base_hashes.insert(path.clone(), hash);
|
||||
report.pushed.push(path.clone());
|
||||
}
|
||||
PushResult::Conflict {
|
||||
server_hash,
|
||||
server_text,
|
||||
} => {
|
||||
let binary = !is_text_file(path);
|
||||
report.conflicts.push(Conflict {
|
||||
path: path.clone(),
|
||||
local_text: if binary {
|
||||
String::new()
|
||||
} else {
|
||||
String::from_utf8_lossy(&bytes).to_string()
|
||||
},
|
||||
remote_text: server_text.clone(),
|
||||
merged_text: server_text,
|
||||
server_hash,
|
||||
auto_merged: false,
|
||||
binary,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let removed: Vec<String> = meta
|
||||
.base_hashes
|
||||
.keys()
|
||||
.filter(|path| !local_set.contains(*path))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for path in removed {
|
||||
delete_remote_file(server_url, token, &space_id, &path)?;
|
||||
meta.base_hashes.remove(&path);
|
||||
report.deleted_remote.push(path);
|
||||
}
|
||||
|
||||
meta.last_synced_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
store.save_meta(project, meta)?;
|
||||
save_base_snapshots(store, project, project_dir, meta)?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
pub fn clone_space(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
store: &Store,
|
||||
project: &str,
|
||||
project_dir: &Path,
|
||||
space_id: &str,
|
||||
) -> Result<SyncReport, String> {
|
||||
std::fs::create_dir_all(project_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let manifest = get_manifest(server_url, token, space_id)?;
|
||||
let mut meta = store.meta(project)?;
|
||||
meta.space_id = Some(space_id.to_string());
|
||||
meta.entrypoint = manifest.entrypoint.clone();
|
||||
|
||||
let mut report = SyncReport::default();
|
||||
|
||||
for entry in &manifest.files {
|
||||
let remote = pull_file(server_url, token, space_id, &entry.path)?;
|
||||
write_local(project_dir, &entry.path, &remote.bytes()?)?;
|
||||
meta.base_hashes.insert(entry.path.clone(), remote.hash);
|
||||
report.pulled.push(entry.path.clone());
|
||||
}
|
||||
|
||||
meta.last_synced_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
store.save_meta(project, &meta)?;
|
||||
save_base_snapshots(store, project, project_dir, &meta)?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
pub fn save_base_snapshots(
|
||||
store: &Store,
|
||||
project: &str,
|
||||
project_dir: &Path,
|
||||
meta: &ProjectMeta,
|
||||
) -> Result<(), String> {
|
||||
for (path, base) in &meta.base_hashes {
|
||||
let full = project_file_path(project_dir, path)?;
|
||||
let Ok(bytes) = std::fs::read(&full) else {
|
||||
continue;
|
||||
};
|
||||
if content_hash(&bytes) == *base {
|
||||
store.save_base_snapshot(project, path, base, &bytes)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_base_snapshot(store: &Store, project: &str, relative: &str) -> String {
|
||||
store
|
||||
.base_snapshot(project, relative)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|bytes| String::from_utf8_lossy(&bytes).to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn resolve_conflict(
|
||||
store: &Store,
|
||||
project: &str,
|
||||
project_dir: &Path,
|
||||
meta: &mut ProjectMeta,
|
||||
path: &str,
|
||||
content: &str,
|
||||
server_hash: &str,
|
||||
) -> Result<(), String> {
|
||||
write_local(project_dir, path, content.as_bytes())?;
|
||||
meta.base_hashes
|
||||
.insert(path.to_string(), server_hash.to_string());
|
||||
store.save_meta(project, meta)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::assets::is_image;
|
||||
use crate::compiler;
|
||||
use crate::db::Store;
|
||||
use crate::workspace::{read_target_files, resolve_target, workspace_path};
|
||||
|
||||
const MAX_IMAGE_BYTES: u64 = 8 * 1024 * 1024;
|
||||
const MAX_VIEWER_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Thumbnail {
|
||||
pub kind: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
fn modified_seconds(path: &Path) -> i64 {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|meta| meta.modified())
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|elapsed| elapsed.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn mime_for(name: &str) -> &'static str {
|
||||
let lower = name.to_lowercase();
|
||||
if lower.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if lower.ends_with(".gif") {
|
||||
"image/gif"
|
||||
} else if lower.ends_with(".svg") {
|
||||
"image/svg+xml"
|
||||
} else if lower.ends_with(".webp") {
|
||||
"image/webp"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ImageData {
|
||||
pub name: String,
|
||||
pub data: String,
|
||||
pub size: u64,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn read_image(app: &AppHandle, store: &Store, path: &str) -> Result<ImageData, String> {
|
||||
let full = workspace_path(app, store, path)?;
|
||||
let name = full
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !is_image(&name) {
|
||||
return Err("Not an image file".to_string());
|
||||
}
|
||||
|
||||
let size = std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0);
|
||||
if size > MAX_VIEWER_BYTES {
|
||||
return Err("Image is too large to open".to_string());
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
|
||||
let (width, height) = image_dimensions(&bytes);
|
||||
|
||||
Ok(ImageData {
|
||||
data: format!("data:{};base64,{}", mime_for(&name), BASE64.encode(&bytes)),
|
||||
name,
|
||||
size,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn image_dimensions(bytes: &[u8]) -> (Option<u32>, Option<u32>) {
|
||||
if bytes.len() > 24 && bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
|
||||
let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
|
||||
let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||
return (Some(width), Some(height));
|
||||
}
|
||||
|
||||
if bytes.len() > 10 && bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
let mut index = 2usize;
|
||||
while index + 9 < bytes.len() {
|
||||
if bytes[index] != 0xFF {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let marker = bytes[index + 1];
|
||||
if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC
|
||||
{
|
||||
let height = u16::from_be_bytes([bytes[index + 5], bytes[index + 6]]) as u32;
|
||||
let width = u16::from_be_bytes([bytes[index + 7], bytes[index + 8]]) as u32;
|
||||
return (Some(width), Some(height));
|
||||
}
|
||||
let length = u16::from_be_bytes([bytes[index + 2], bytes[index + 3]]) as usize;
|
||||
index += 2 + length;
|
||||
}
|
||||
}
|
||||
|
||||
(None, None)
|
||||
}
|
||||
|
||||
pub fn thumbnail(app: &AppHandle, store: &Store, path: &str) -> Result<Thumbnail, String> {
|
||||
let full = workspace_path(app, store, path)?;
|
||||
let name = full
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let image = is_image(&name);
|
||||
if !image && !name.to_lowercase().ends_with(".typ") {
|
||||
return Err("No preview available".to_string());
|
||||
}
|
||||
|
||||
let modified = modified_seconds(&full);
|
||||
|
||||
if let Some((kind, data)) = store.thumbnail(path, modified)? {
|
||||
return Ok(Thumbnail { kind, data });
|
||||
}
|
||||
|
||||
let thumbnail = if image {
|
||||
let size = std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0);
|
||||
if size > MAX_IMAGE_BYTES {
|
||||
return Err("Image is too large to preview".to_string());
|
||||
}
|
||||
let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
|
||||
Thumbnail {
|
||||
kind: "image".to_string(),
|
||||
data: format!("data:{};base64,{}", mime_for(&name), BASE64.encode(&bytes)),
|
||||
}
|
||||
} else {
|
||||
let target = resolve_target(app, store, path)?;
|
||||
let files = read_target_files(app, store, &target)?;
|
||||
|
||||
let result = compiler::compile_to_svg(target.entrypoint, files)
|
||||
.map_err(|_| "Document does not compile".to_string())?;
|
||||
|
||||
let svg = result
|
||||
.pages
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or("Document has no pages")?;
|
||||
|
||||
Thumbnail {
|
||||
kind: "svg".to_string(),
|
||||
data: svg,
|
||||
}
|
||||
};
|
||||
|
||||
store.save_thumbnail(path, &thumbnail.kind, &thumbnail.data, modified)?;
|
||||
Ok(thumbnail)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::db::Store;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub const PROJECT_META_FILE: &str = ".typst-desktop.json";
|
||||
|
||||
const TEXT_EXTENSIONS: [&str; 10] = [
|
||||
"typ", "toml", "bib", "csl", "yml", "yaml", "json", "md", "txt", "csv",
|
||||
];
|
||||
|
||||
pub fn is_text_file(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| TEXT_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn content_hash(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Settings {
|
||||
pub workspace_root: String,
|
||||
pub server_url: String,
|
||||
#[serde(default)]
|
||||
pub device_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub account_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub account_username: Option<String>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn fallback(app: &AppHandle) -> Self {
|
||||
let home = app
|
||||
.path()
|
||||
.home_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."));
|
||||
Settings {
|
||||
workspace_root: home.join("typst").to_string_lossy().to_string(),
|
||||
server_url: "http://localhost:3000".to_string(),
|
||||
device_token: None,
|
||||
account_email: None,
|
||||
account_username: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_settings(app: &AppHandle, store: &Store) -> Result<Settings, String> {
|
||||
match store.settings()? {
|
||||
Some(settings) => Ok(settings),
|
||||
None => {
|
||||
let settings = Settings::fallback(app);
|
||||
store.save_settings(&settings)?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_settings(store: &Store, settings: &Settings) -> Result<(), String> {
|
||||
store.save_settings(settings)
|
||||
}
|
||||
|
||||
pub fn workspace_root(app: &AppHandle, store: &Store) -> Result<PathBuf, String> {
|
||||
let settings = load_settings(app, store)?;
|
||||
let root = PathBuf::from(&settings.workspace_root);
|
||||
std::fs::create_dir_all(&root)
|
||||
.map_err(|e| format!("Cannot create workspace directory: {}", e))?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ProjectMeta {
|
||||
pub entrypoint: String,
|
||||
pub space_id: Option<String>,
|
||||
pub last_synced_at: Option<String>,
|
||||
pub base_hashes: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for ProjectMeta {
|
||||
fn default() -> Self {
|
||||
ProjectMeta {
|
||||
entrypoint: "main.typ".to_string(),
|
||||
space_id: None,
|
||||
last_synced_at: None,
|
||||
base_hashes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn workspace_path(app: &AppHandle, store: &Store, relative: &str) -> Result<PathBuf, String> {
|
||||
let root = workspace_root(app, store)?;
|
||||
if relative.is_empty() {
|
||||
return Ok(root);
|
||||
}
|
||||
project_file_path(&root, relative)
|
||||
}
|
||||
|
||||
pub fn is_project_dir(path: &Path) -> bool {
|
||||
path.join(PROJECT_META_FILE).exists() || path.join("typst.toml").exists()
|
||||
}
|
||||
|
||||
pub fn is_typst_file(path: &str) -> bool {
|
||||
path.to_lowercase().ends_with(".typ")
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BrowseEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub kind: String,
|
||||
pub size: u64,
|
||||
pub modified: Option<String>,
|
||||
pub space_id: Option<String>,
|
||||
pub last_synced_at: Option<String>,
|
||||
pub child_count: usize,
|
||||
}
|
||||
|
||||
fn modified_at(path: &Path) -> Option<String> {
|
||||
let modified = std::fs::metadata(path).ok()?.modified().ok()?;
|
||||
let datetime: chrono::DateTime<chrono::Utc> = modified.into();
|
||||
Some(datetime.to_rfc3339())
|
||||
}
|
||||
|
||||
pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result<Vec<BrowseEntry>, String> {
|
||||
let dir = workspace_path(app, store, relative)?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("'{}' is not a folder", relative));
|
||||
}
|
||||
|
||||
let prefix = if relative.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}/", relative.trim_end_matches('/'))
|
||||
};
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for entry in std::fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = format!("{}{}", prefix, name);
|
||||
let full = entry.path();
|
||||
|
||||
if full.is_dir() {
|
||||
let project = is_project_dir(&full) || store.has_project(&path)?;
|
||||
let meta = if project {
|
||||
Some(store.meta(&path)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let child_count = std::fs::read_dir(&full)
|
||||
.map(|children| {
|
||||
children
|
||||
.filter_map(|child| child.ok())
|
||||
.filter(|child| {
|
||||
!child.file_name().to_string_lossy().starts_with('.')
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
entries.push(BrowseEntry {
|
||||
name,
|
||||
path,
|
||||
kind: if project { "project" } else { "folder" }.to_string(),
|
||||
size: 0,
|
||||
modified: modified_at(&full),
|
||||
space_id: meta.as_ref().and_then(|m| m.space_id.clone()),
|
||||
last_synced_at: meta.as_ref().and_then(|m| m.last_synced_at.clone()),
|
||||
child_count,
|
||||
});
|
||||
} else {
|
||||
let kind = if is_typst_file(&name) { "document" } else { "file" };
|
||||
entries.push(BrowseEntry {
|
||||
name,
|
||||
path,
|
||||
kind: kind.to_string(),
|
||||
size: std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0),
|
||||
modified: modified_at(&full),
|
||||
space_id: None,
|
||||
last_synced_at: None,
|
||||
child_count: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
let rank = |kind: &str| match kind {
|
||||
"project" => 0,
|
||||
"folder" => 1,
|
||||
"document" => 2,
|
||||
_ => 3,
|
||||
};
|
||||
rank(&a.kind)
|
||||
.cmp(&rank(&b.kind))
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub struct Target {
|
||||
pub root: PathBuf,
|
||||
pub entrypoint: String,
|
||||
pub standalone: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_target(app: &AppHandle, store: &Store, path: &str) -> Result<Target, String> {
|
||||
let full = workspace_path(app, store, path)?;
|
||||
|
||||
if full.is_dir() {
|
||||
let meta = store.meta(path)?;
|
||||
return Ok(Target {
|
||||
root: full,
|
||||
entrypoint: meta.entrypoint,
|
||||
standalone: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !full.is_file() {
|
||||
return Err(format!("'{}' does not exist", path));
|
||||
}
|
||||
|
||||
let parent = full
|
||||
.parent()
|
||||
.ok_or("File has no parent folder")?
|
||||
.to_path_buf();
|
||||
let name = full
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.ok_or("File has no name")?;
|
||||
|
||||
Ok(Target {
|
||||
root: parent,
|
||||
entrypoint: name,
|
||||
standalone: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_target_files(
|
||||
app: &AppHandle,
|
||||
store: &Store,
|
||||
target: &Target,
|
||||
) -> Result<HashMap<String, Vec<u8>>, String> {
|
||||
let mut map = crate::assets::asset_files(app, store);
|
||||
|
||||
if !target.standalone {
|
||||
for (path, bytes) in read_all_files(&target.root)? {
|
||||
map.insert(path, bytes);
|
||||
}
|
||||
return Ok(map);
|
||||
}
|
||||
|
||||
collect_loose_files(&target.root, "", &mut map)?;
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn collect_loose_files(
|
||||
dir: &Path,
|
||||
prefix: &str,
|
||||
map: &mut HashMap<String, Vec<u8>>,
|
||||
) -> Result<(), String> {
|
||||
for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
let key = if prefix.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("{}/{}", prefix, name)
|
||||
};
|
||||
|
||||
if path.is_dir() {
|
||||
if is_project_dir(&path) {
|
||||
continue;
|
||||
}
|
||||
collect_loose_files(&path, &key, map)?;
|
||||
} else if let Ok(bytes) = std::fs::read(&path) {
|
||||
map.insert(key, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn project_file_path(project_dir: &Path, relative: &str) -> Result<PathBuf, String> {
|
||||
if relative.is_empty() {
|
||||
return Err("Path cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let mut resolved = project_dir.to_path_buf();
|
||||
for component in relative.replace('\\', "/").split('/') {
|
||||
if component.is_empty() || component == "." {
|
||||
continue;
|
||||
}
|
||||
if component == ".." {
|
||||
return Err("Path cannot escape the project".to_string());
|
||||
}
|
||||
resolved.push(component);
|
||||
}
|
||||
|
||||
if !resolved.starts_with(project_dir) {
|
||||
return Err("Path cannot escape the project".to_string());
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
pub fn relative_path(project_dir: &Path, path: &Path) -> Option<String> {
|
||||
path.strip_prefix(project_dir)
|
||||
.ok()
|
||||
.map(|rest| rest.to_string_lossy().replace('\\', "/"))
|
||||
}
|
||||
|
||||
pub fn collect_files(project_dir: &Path) -> Result<Vec<String>, String> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
for entry in WalkDir::new(project_dir).into_iter().filter_map(|e| e.ok()) {
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(relative) = relative_path(project_dir, entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
if relative == PROJECT_META_FILE || relative.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
files.push(relative);
|
||||
}
|
||||
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn read_all_files(project_dir: &Path) -> Result<HashMap<String, Vec<u8>>, String> {
|
||||
let mut map = HashMap::new();
|
||||
for relative in collect_files(project_dir)? {
|
||||
let full = project_file_path(project_dir, &relative)?;
|
||||
let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
|
||||
map.insert(relative, bytes);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FileEntry {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub is_text: bool,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
pub fn list_files(project_dir: &Path) -> Result<Vec<FileEntry>, String> {
|
||||
let mut entries = Vec::new();
|
||||
for relative in collect_files(project_dir)? {
|
||||
let full = project_file_path(project_dir, &relative)?;
|
||||
let size = std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0);
|
||||
let name = relative
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(&relative)
|
||||
.to_string();
|
||||
entries.push(FileEntry {
|
||||
is_text: is_text_file(&relative),
|
||||
path: relative,
|
||||
name,
|
||||
size,
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub const NEW_PROJECT_MAIN: &str = "= New Project\n\nStart writing here.\n";
|
||||
|
||||
pub fn manifest_for(name: &str) -> String {
|
||||
let slug: String = name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let slug = slug.trim_matches('-').to_string();
|
||||
let slug = if slug.is_empty() {
|
||||
"my-project".to_string()
|
||||
} else {
|
||||
slug
|
||||
};
|
||||
|
||||
format!(
|
||||
"[package]\nname = \"{slug}\"\nversion = \"0.1.0\"\nentrypoint = \"main.typ\"\nauthors = [\"Anonymous\"]\nlicense = \"MIT\"\ndescription = \"\"\n"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use chrono::Datelike;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use typst::diag::{FileError, FileResult};
|
||||
use typst::foundations::{Bytes, Datetime, Duration};
|
||||
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
|
||||
use typst::text::{Font, FontBook};
|
||||
use typst::World;
|
||||
use typst::{Library, LibraryExt};
|
||||
use typst_kit::downloader::SystemDownloader;
|
||||
use typst_kit::packages::SystemPackages;
|
||||
|
||||
pub struct ProjectWorld {
|
||||
library: typst::utils::LazyHash<Library>,
|
||||
main: FileId,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
book: typst::utils::LazyHash<FontBook>,
|
||||
fonts: Vec<Font>,
|
||||
packages: SystemPackages,
|
||||
}
|
||||
|
||||
fn normalize_path(path: &str) -> String {
|
||||
path.trim_start_matches('/').replace('\\', "/")
|
||||
}
|
||||
|
||||
impl ProjectWorld {
|
||||
pub fn new(entrypoint: String, files: HashMap<String, Vec<u8>>, enable_html: bool) -> Self {
|
||||
let main = FileId::new(RootedPath::new(
|
||||
VirtualRoot::Project,
|
||||
VirtualPath::new(&entrypoint).unwrap_or_else(|_| VirtualPath::new("main.typ").unwrap()),
|
||||
));
|
||||
|
||||
let downloader = SystemDownloader::new("TypstDesktop (typst-kit)");
|
||||
let packages = SystemPackages::new(downloader);
|
||||
|
||||
let mut book = FontBook::new();
|
||||
let mut fonts = Vec::new();
|
||||
|
||||
for data in typst_assets::fonts() {
|
||||
let buffer = Bytes::new(data);
|
||||
for font in Font::iter(buffer) {
|
||||
book.push(font.info().clone());
|
||||
fonts.push(font);
|
||||
}
|
||||
}
|
||||
|
||||
for (name, data) in &files {
|
||||
let lower = name.to_lowercase();
|
||||
if [".ttf", ".otf", ".ttc", ".otc"]
|
||||
.iter()
|
||||
.any(|ext| lower.ends_with(ext))
|
||||
{
|
||||
for font in Font::iter(Bytes::new(data.clone())) {
|
||||
book.push(font.info().clone());
|
||||
fonts.push(font);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let library = if enable_html {
|
||||
Library::builder()
|
||||
.with_features([typst::Feature::Html].into_iter().collect())
|
||||
.build()
|
||||
} else {
|
||||
Library::builder().build()
|
||||
};
|
||||
|
||||
Self {
|
||||
library: typst::utils::LazyHash::new(library),
|
||||
main,
|
||||
files,
|
||||
book: typst::utils::LazyHash::new(book),
|
||||
fonts,
|
||||
packages,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_bytes(&self, id: FileId) -> FileResult<Vec<u8>> {
|
||||
let path = normalize_path(id.vpath().get_without_slash());
|
||||
|
||||
if let VirtualRoot::Package(package) = id.root() {
|
||||
let root = self
|
||||
.packages
|
||||
.obtain(package)
|
||||
.map_err(|e| FileError::Other(Some(e.to_string().into())))?;
|
||||
return root.load(id.vpath()).map(|bytes| bytes.to_vec());
|
||||
}
|
||||
|
||||
self.files
|
||||
.get(&path)
|
||||
.cloned()
|
||||
.ok_or_else(|| FileError::NotFound(path.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl World for ProjectWorld {
|
||||
fn library(&self) -> &typst::utils::LazyHash<Library> {
|
||||
&self.library
|
||||
}
|
||||
|
||||
fn book(&self) -> &typst::utils::LazyHash<FontBook> {
|
||||
&self.book
|
||||
}
|
||||
|
||||
fn main(&self) -> FileId {
|
||||
self.main
|
||||
}
|
||||
|
||||
fn source(&self, id: FileId) -> FileResult<Source> {
|
||||
let data = self.load_bytes(id)?;
|
||||
let text = std::str::from_utf8(&data)
|
||||
.map_err(|_| FileError::InvalidUtf8)?
|
||||
.to_owned();
|
||||
Ok(Source::new(id, text))
|
||||
}
|
||||
|
||||
fn file(&self, id: FileId) -> FileResult<Bytes> {
|
||||
let data = self.load_bytes(id)?;
|
||||
Ok(Bytes::new(data))
|
||||
}
|
||||
|
||||
fn font(&self, index: usize) -> Option<Font> {
|
||||
self.fonts.get(index).cloned()
|
||||
}
|
||||
|
||||
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
|
||||
let now = chrono::Local::now();
|
||||
let date = if let Some(offset) = offset {
|
||||
let offset_secs = offset.hours() as i32 * 3600;
|
||||
let offset_chrono = chrono::FixedOffset::east_opt(offset_secs)?;
|
||||
now.with_timezone(&offset_chrono).date_naive()
|
||||
} else {
|
||||
now.date_naive()
|
||||
};
|
||||
|
||||
Datetime::from_ymd(date.year(), date.month() as u8, date.day() as u8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "typst-desktop",
|
||||
"version": "0.1.0",
|
||||
"identifier": "co.sirblob.typst-desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Typst Desktop",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"decorations": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/[email protected]",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||