mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 04:22:57 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
# Trackeep Desktop (Tauri v2)
|
||||
|
||||
Trackeep Desktop is a native shell for Linux, Windows, and macOS.
|
||||
|
||||
It opens your own self-hosted Trackeep instance URL in a native Tauri WebView, so all application behavior stays identical to your web deployment:
|
||||
|
||||
- authentication/session management
|
||||
- file upload/download
|
||||
- realtime connections and API calls
|
||||
- server-side update logic from your Trackeep backend
|
||||
|
||||
Because the desktop main window loads your hosted instance directly, the UI and behavior are the same as the web app.
|
||||
|
||||
## Run in development
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
npm install
|
||||
npm run tauri:dev
|
||||
```
|
||||
|
||||
## Build desktop bundles
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
npm install
|
||||
npm run tauri:build
|
||||
```
|
||||
|
||||
Generated bundles appear under `desktop/src-tauri/target/release/bundle/`.
|
||||
|
||||
## Instance configuration flow
|
||||
|
||||
On first launch, the app shows a setup screen where the user enters the Trackeep instance URL, for example:
|
||||
|
||||
- `https://trackeep.example.com`
|
||||
- `http://192.168.1.50:80`
|
||||
|
||||
The URL is stored in Tauri's app config directory as `instance.json` (platform-specific location).
|
||||
|
||||
Users can change instance from the desktop app menu:
|
||||
|
||||
- `Trackeep -> Desktop Integrations...`
|
||||
|
||||
## Native desktop features
|
||||
|
||||
Desktop Integrations includes optional native capabilities:
|
||||
|
||||
- API key/token for desktop uploads
|
||||
- token permission validation (`files:read`, `files:write`, `files:share`)
|
||||
- local sync folder selection
|
||||
- direct native file picker upload (`Upload Files...`)
|
||||
- quick share flow (`Quick Share Files...`) that uploads, creates share links, and copies links to clipboard
|
||||
- folder-to-instance sync (`Sync Folder Now`)
|
||||
- open sync folder in OS file manager
|
||||
|
||||
For cloud storage workflows, point the sync folder to a cloud-synced directory (OneDrive, Dropbox, Google Drive desktop client, iCloud Drive).
|
||||
|
||||
Create an API key in Trackeep Settings -> Browser Extension with:
|
||||
|
||||
- `files:read`
|
||||
- `files:write`
|
||||
- `files:share` (recommended for quick-share links)
|
||||
|
||||
## Cross-platform prerequisites
|
||||
|
||||
Tauri requires native toolchains per platform. Follow official setup docs:
|
||||
|
||||
- https://v2.tauri.app/start/prerequisites/
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Trackeep Desktop Integrations</title>
|
||||
<link rel="stylesheet" href="/src/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header>
|
||||
<h1>Trackeep Desktop Integrations</h1>
|
||||
<p>
|
||||
The main window always shows your exact Trackeep web UI. Configure the
|
||||
instance and native desktop features here.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form id="connect-form" autocomplete="off">
|
||||
<label for="instance-url">Instance URL</label>
|
||||
<input
|
||||
id="instance-url"
|
||||
name="instance-url"
|
||||
type="url"
|
||||
placeholder="https://trackeep.your-domain.com"
|
||||
required
|
||||
/>
|
||||
|
||||
<label for="api-key">Desktop API key / token (optional)</label>
|
||||
<div class="row">
|
||||
<input
|
||||
id="api-key"
|
||||
name="api-key"
|
||||
type="password"
|
||||
placeholder="tk_xxx or JWT token"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button id="validate-token" type="button">Validate Permissions</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
For desktop uploads/sync, use a key with <code>files:read</code> and
|
||||
<code>files:write</code> permissions.
|
||||
</p>
|
||||
<p id="permission-status" class="hint"></p>
|
||||
|
||||
<label for="sync-folder">Local sync folder (optional)</label>
|
||||
<div class="row">
|
||||
<input
|
||||
id="sync-folder"
|
||||
name="sync-folder"
|
||||
type="text"
|
||||
placeholder="Choose a local folder"
|
||||
readonly
|
||||
/>
|
||||
<button id="choose-sync-folder" type="button">Choose Folder</button>
|
||||
<button id="open-sync-folder" type="button">Open</button>
|
||||
</div>
|
||||
|
||||
<button id="connect-button" type="submit">Save and Open Instance</button>
|
||||
</form>
|
||||
|
||||
<section class="native-actions">
|
||||
<h2>Native Actions</h2>
|
||||
<div class="row">
|
||||
<button id="upload-now" type="button">Upload Files Now</button>
|
||||
<button id="quick-share-now" type="button">Quick Share Files</button>
|
||||
<button id="sync-now" type="button">Sync Folder Now</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p id="status" class="status" aria-live="polite"></p>
|
||||
<p id="error" class="error" aria-live="assertive"></p>
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
Menu shortcuts: <strong>Trackeep -> Desktop Integrations...</strong>,
|
||||
<strong>Upload Files...</strong>, <strong>Quick Share Files...</strong>,
|
||||
<strong>Sync Folder Now</strong>
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1259
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "trackeep-desktop",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 1420 --strictPort",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 1420 --strictPort",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
Generated
+6071
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "trackeep_desktop"
|
||||
version = "1.0.0"
|
||||
description = "Trackeep desktop shell"
|
||||
authors = ["Trackeep Team"]
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
arboard = "3"
|
||||
mime_guess = "2"
|
||||
open = "5"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] }
|
||||
rfd = "0.15"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
url = "2"
|
||||
walkdir = "2"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability for Trackeep Desktop windows",
|
||||
"windows": [
|
||||
"setup",
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capability for Trackeep Desktop windows","local":true,"windows":["setup","main"],"permissions":["core:default"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 915 B |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,965 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use arboard::Clipboard;
|
||||
use reqwest::blocking::{multipart, Client};
|
||||
use rfd::{FileDialog, MessageButtons, MessageDialog, MessageLevel};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tauri::menu::{Menu, MenuItem, Submenu};
|
||||
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindowBuilder};
|
||||
use url::Url;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
const CONFIG_FILE_NAME: &str = "instance.json";
|
||||
const SETUP_WINDOW_LABEL: &str = "setup";
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
struct DesktopConfig {
|
||||
instance_url: Option<String>,
|
||||
api_key: Option<String>,
|
||||
sync_folder: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct DesktopConfigView {
|
||||
instance_url: Option<String>,
|
||||
api_key: Option<String>,
|
||||
sync_folder: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
struct UploadSummary {
|
||||
uploaded: usize,
|
||||
shared: usize,
|
||||
failed: usize,
|
||||
shared_links: Vec<String>,
|
||||
clipboard_copied: bool,
|
||||
failures: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct TokenValidationResult {
|
||||
valid: bool,
|
||||
token_type: String,
|
||||
permissions: Vec<String>,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct UploadedFileResponse {
|
||||
id: u64,
|
||||
original_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct FileShareResponse {
|
||||
public_share_url: Option<String>,
|
||||
share_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RuntimeDesktopConfig {
|
||||
instance_url: String,
|
||||
api_key: String,
|
||||
sync_folder: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_desktop_config<R: Runtime>(app: AppHandle<R>) -> Result<DesktopConfigView, String> {
|
||||
let config = load_config(&app)?;
|
||||
|
||||
let instance_url = match config.instance_url {
|
||||
Some(value) => Some(normalize_instance_url(&value)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let sync_folder = match config.sync_folder {
|
||||
Some(value) => Some(normalize_sync_folder(&value)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(DesktopConfigView {
|
||||
instance_url,
|
||||
api_key: config
|
||||
.api_key
|
||||
.and_then(|value| normalize_api_key(Some(value)).ok().flatten()),
|
||||
sync_folder,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn connect_instance<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
instance_url: String,
|
||||
api_key: Option<String>,
|
||||
sync_folder: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let normalized_instance_url = normalize_instance_url(&instance_url)?;
|
||||
let normalized_api_key = normalize_api_key(api_key)?;
|
||||
let normalized_sync_folder = normalize_optional_sync_folder(sync_folder)?;
|
||||
|
||||
let config = DesktopConfig {
|
||||
instance_url: Some(normalized_instance_url.clone()),
|
||||
api_key: normalized_api_key,
|
||||
sync_folder: normalized_sync_folder,
|
||||
};
|
||||
|
||||
save_config(&app, &config)?;
|
||||
open_main_window(&app, &normalized_instance_url)?;
|
||||
close_window_if_exists(&app, SETUP_WINDOW_LABEL);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn select_sync_folder() -> Result<Option<String>, String> {
|
||||
Ok(FileDialog::new()
|
||||
.set_title("Select Trackeep sync folder")
|
||||
.pick_folder()
|
||||
.map(|path| path_to_string(&path)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_sync_folder<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
|
||||
let config = load_config(&app)?;
|
||||
let Some(sync_folder) = config.sync_folder else {
|
||||
return Err("No sync folder configured. Open Desktop Integrations to set one.".into());
|
||||
};
|
||||
|
||||
let normalized = normalize_sync_folder(&sync_folder)?;
|
||||
open::that(&normalized).map_err(|error| format!("Failed to open sync folder: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn upload_files_now<R: Runtime>(app: AppHandle<R>) -> Result<UploadSummary, String> {
|
||||
let runtime_config = load_runtime_config(&app)?;
|
||||
|
||||
let selected_files = FileDialog::new()
|
||||
.set_title("Select files to upload to Trackeep")
|
||||
.pick_files();
|
||||
|
||||
let Some(paths) = selected_files else {
|
||||
return Ok(UploadSummary::default());
|
||||
};
|
||||
|
||||
upload_paths(&runtime_config, &paths, None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn sync_folder_now<R: Runtime>(app: AppHandle<R>) -> Result<UploadSummary, String> {
|
||||
let runtime_config = load_runtime_config(&app)?;
|
||||
let sync_folder = runtime_config.sync_folder.clone().ok_or_else(|| {
|
||||
"No sync folder configured. Open Desktop Integrations to set one.".to_string()
|
||||
})?;
|
||||
|
||||
let files = collect_files(&sync_folder)?;
|
||||
if files.is_empty() {
|
||||
return Ok(UploadSummary::default());
|
||||
}
|
||||
|
||||
upload_paths(&runtime_config, &files, Some(&sync_folder))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn quick_share_files<R: Runtime>(app: AppHandle<R>) -> Result<UploadSummary, String> {
|
||||
let runtime_config = load_runtime_config(&app)?;
|
||||
|
||||
let selected_files = FileDialog::new()
|
||||
.set_title("Select files to quick share")
|
||||
.pick_files();
|
||||
|
||||
let Some(paths) = selected_files else {
|
||||
return Ok(UploadSummary::default());
|
||||
};
|
||||
|
||||
quick_share_paths(&runtime_config, &paths)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn validate_integration_token(
|
||||
instance_url: String,
|
||||
token: String,
|
||||
) -> Result<TokenValidationResult, String> {
|
||||
let normalized_instance_url = normalize_instance_url(&instance_url)?;
|
||||
let normalized_token = normalize_api_key(Some(token))?
|
||||
.ok_or_else(|| "Token is required for validation.".to_string())?;
|
||||
|
||||
validate_token_permissions(&normalized_instance_url, &normalized_token)
|
||||
}
|
||||
|
||||
fn normalize_instance_url(raw: &str) -> Result<String, String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Instance URL is required.".into());
|
||||
}
|
||||
|
||||
let mut url = Url::parse(trimmed).map_err(|_| {
|
||||
"Instance URL must be a valid absolute URL, for example https://trackeep.your-domain.com"
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
match url.scheme() {
|
||||
"http" | "https" => {}
|
||||
_ => {
|
||||
return Err("Only http:// or https:// instance URLs are supported.".into());
|
||||
}
|
||||
}
|
||||
|
||||
url.set_fragment(None);
|
||||
|
||||
let mut normalized = url.to_string();
|
||||
while normalized.ends_with('/') {
|
||||
normalized.pop();
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn normalize_api_key(raw: Option<String>) -> Result<Option<String>, String> {
|
||||
let Some(value) = raw else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if trimmed.contains(char::is_whitespace) {
|
||||
return Err("API key/token must not contain spaces.".into());
|
||||
}
|
||||
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
|
||||
fn normalize_sync_folder(raw: &str) -> Result<String, String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Sync folder path is empty.".into());
|
||||
}
|
||||
|
||||
let folder = PathBuf::from(trimmed);
|
||||
if !folder.exists() {
|
||||
return Err(format!("Sync folder does not exist: {}", folder.display()));
|
||||
}
|
||||
if !folder.is_dir() {
|
||||
return Err(format!(
|
||||
"Sync folder is not a directory: {}",
|
||||
folder.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(path_to_string(&folder))
|
||||
}
|
||||
|
||||
fn normalize_optional_sync_folder(raw: Option<String>) -> Result<Option<String>, String> {
|
||||
let Some(value) = raw else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if value.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(normalize_sync_folder(&value)?))
|
||||
}
|
||||
|
||||
fn config_path<R: Runtime>(app: &AppHandle<R>) -> Result<PathBuf, String> {
|
||||
let config_dir = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|error| format!("Could not resolve config directory: {error}"))?;
|
||||
|
||||
fs::create_dir_all(&config_dir)
|
||||
.map_err(|error| format!("Could not create config directory: {error}"))?;
|
||||
|
||||
Ok(config_dir.join(CONFIG_FILE_NAME))
|
||||
}
|
||||
|
||||
fn load_config<R: Runtime>(app: &AppHandle<R>) -> Result<DesktopConfig, String> {
|
||||
let path = config_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(DesktopConfig::default());
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(path)
|
||||
.map_err(|error| format!("Could not read desktop config file: {error}"))?;
|
||||
|
||||
serde_json::from_str::<DesktopConfig>(&raw)
|
||||
.map_err(|error| format!("Desktop config is not valid JSON: {error}"))
|
||||
}
|
||||
|
||||
fn save_config<R: Runtime>(app: &AppHandle<R>, config: &DesktopConfig) -> Result<(), String> {
|
||||
let path = config_path(app)?;
|
||||
let serialized = serde_json::to_string_pretty(config)
|
||||
.map_err(|error| format!("Could not serialize desktop config: {error}"))?;
|
||||
|
||||
fs::write(path, serialized).map_err(|error| format!("Could not save desktop config: {error}"))
|
||||
}
|
||||
|
||||
fn load_runtime_config<R: Runtime>(app: &AppHandle<R>) -> Result<RuntimeDesktopConfig, String> {
|
||||
let config = load_config(app)?;
|
||||
|
||||
let instance_url = config.instance_url.ok_or_else(|| {
|
||||
"No instance URL configured. Open Desktop Integrations first.".to_string()
|
||||
})?;
|
||||
|
||||
let normalized_instance_url = normalize_instance_url(&instance_url)?;
|
||||
|
||||
let api_key = config
|
||||
.api_key
|
||||
.and_then(|value| normalize_api_key(Some(value)).ok())
|
||||
.flatten()
|
||||
.ok_or_else(|| {
|
||||
"No API key/token configured. Add one in Desktop Integrations first.".to_string()
|
||||
})?;
|
||||
|
||||
let sync_folder = match config.sync_folder {
|
||||
Some(folder) => Some(PathBuf::from(normalize_sync_folder(&folder)?)),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(RuntimeDesktopConfig {
|
||||
instance_url: normalized_instance_url,
|
||||
api_key,
|
||||
sync_folder,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_files(folder: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
for entry in WalkDir::new(folder).follow_links(false) {
|
||||
let entry = entry.map_err(|error| format!("Failed to scan sync folder: {error}"))?;
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_name = entry.file_name().to_string_lossy();
|
||||
if file_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if file_name.eq_ignore_ascii_case("Thumbs.db")
|
||||
|| file_name.eq_ignore_ascii_case("desktop.ini")
|
||||
|| file_name.eq_ignore_ascii_case(".DS_Store")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parent) = entry.path().parent() {
|
||||
if parent
|
||||
.components()
|
||||
.any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(metadata) = entry.metadata() {
|
||||
if metadata.len() == 0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if entry.file_type().is_file() {
|
||||
files.push(entry.path().to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn upload_paths(
|
||||
config: &RuntimeDesktopConfig,
|
||||
paths: &[PathBuf],
|
||||
sync_root: Option<&Path>,
|
||||
) -> Result<UploadSummary, String> {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|error| format!("Failed to initialize HTTP client: {error}"))?;
|
||||
|
||||
let upload_url = format!("{}/api/v1/files/upload", config.instance_url);
|
||||
let auth_header = format!("Bearer {}", config.api_key);
|
||||
|
||||
let mut summary = UploadSummary::default();
|
||||
|
||||
for path in paths {
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let description = sync_root.and_then(|root| {
|
||||
path.strip_prefix(root)
|
||||
.ok()
|
||||
.map(|relative| format!("Desktop sync: {}", relative.display()))
|
||||
});
|
||||
|
||||
match upload_single_file(&client, &upload_url, &auth_header, path, description) {
|
||||
Ok(_) => {
|
||||
summary.uploaded += 1;
|
||||
}
|
||||
Err(error) => {
|
||||
summary.failed += 1;
|
||||
summary
|
||||
.failures
|
||||
.push(format!("{} -> {error}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn upload_single_file(
|
||||
client: &Client,
|
||||
upload_url: &str,
|
||||
auth_header: &str,
|
||||
file_path: &Path,
|
||||
description: Option<String>,
|
||||
) -> Result<UploadedFileResponse, String> {
|
||||
let filename = file_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| format!("Invalid file name: {}", file_path.display()))?
|
||||
.to_string();
|
||||
|
||||
let file =
|
||||
fs::File::open(file_path).map_err(|error| format!("Could not open local file: {error}"))?;
|
||||
|
||||
let mime = mime_guess::from_path(file_path).first_or_octet_stream();
|
||||
|
||||
let part = multipart::Part::reader(file)
|
||||
.file_name(filename)
|
||||
.mime_str(mime.essence_str())
|
||||
.map_err(|error| format!("Could not build multipart payload: {error}"))?;
|
||||
|
||||
let mut form = multipart::Form::new().part("file", part);
|
||||
if let Some(value) = description {
|
||||
form = form.text("description", value);
|
||||
}
|
||||
|
||||
let response = client
|
||||
.post(upload_url)
|
||||
.header("Authorization", auth_header)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.map_err(|error| format!("Upload request failed: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().unwrap_or_default();
|
||||
let trimmed = body.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("HTTP {status}"));
|
||||
}
|
||||
|
||||
return Err(format!("HTTP {status}: {}", truncate(trimmed, 180)));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<UploadedFileResponse>()
|
||||
.map_err(|error| format!("Failed to parse upload response: {error}"))
|
||||
}
|
||||
|
||||
fn quick_share_paths(
|
||||
config: &RuntimeDesktopConfig,
|
||||
paths: &[PathBuf],
|
||||
) -> Result<UploadSummary, String> {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|error| format!("Failed to initialize HTTP client: {error}"))?;
|
||||
|
||||
let upload_url = format!("{}/api/v1/files/upload", config.instance_url);
|
||||
let auth_header = format!("Bearer {}", config.api_key);
|
||||
|
||||
let mut summary = UploadSummary::default();
|
||||
let mut links = Vec::new();
|
||||
|
||||
for path in paths {
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match upload_single_file(&client, &upload_url, &auth_header, path, None) {
|
||||
Ok(uploaded) => {
|
||||
summary.uploaded += 1;
|
||||
|
||||
match create_file_share(
|
||||
&client,
|
||||
&config.instance_url,
|
||||
&auth_header,
|
||||
uploaded.id,
|
||||
&uploaded.original_name,
|
||||
) {
|
||||
Ok(link) => {
|
||||
summary.shared += 1;
|
||||
links.push(link);
|
||||
}
|
||||
Err(error) => {
|
||||
summary.failed += 1;
|
||||
summary
|
||||
.failures
|
||||
.push(format!("{} -> share failed: {error}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
summary.failed += 1;
|
||||
summary
|
||||
.failures
|
||||
.push(format!("{} -> upload failed: {error}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !links.is_empty() {
|
||||
summary.shared_links = links.clone();
|
||||
summary.clipboard_copied = copy_links_to_clipboard(&links).is_ok();
|
||||
}
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn create_file_share(
|
||||
client: &Client,
|
||||
instance_url: &str,
|
||||
auth_header: &str,
|
||||
file_id: u64,
|
||||
title: &str,
|
||||
) -> Result<String, String> {
|
||||
let endpoint = format!("{instance_url}/api/v1/files/{file_id}/share");
|
||||
let payload = serde_json::json!({
|
||||
"title": format!("Shared from desktop: {title}"),
|
||||
"allow_download": true
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(endpoint)
|
||||
.header("Authorization", auth_header)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.map_err(|error| format!("Share request failed: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().unwrap_or_default();
|
||||
let trimmed = body.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("HTTP {status}"));
|
||||
}
|
||||
return Err(format!("HTTP {status}: {}", truncate(trimmed, 180)));
|
||||
}
|
||||
|
||||
let share = response
|
||||
.json::<FileShareResponse>()
|
||||
.map_err(|error| format!("Failed to parse share response: {error}"))?;
|
||||
|
||||
if let Some(public) = share.public_share_url {
|
||||
if !public.trim().is_empty() {
|
||||
return Ok(public);
|
||||
}
|
||||
}
|
||||
|
||||
if share.share_url.starts_with("http://") || share.share_url.starts_with("https://") {
|
||||
return Ok(share.share_url);
|
||||
}
|
||||
|
||||
let raw_share_url = share.share_url.trim().to_string();
|
||||
let mut base = instance_url.trim_end_matches('/').to_string();
|
||||
let suffix = if raw_share_url.starts_with('/') {
|
||||
raw_share_url
|
||||
} else {
|
||||
format!("/{}", raw_share_url)
|
||||
};
|
||||
base.push_str(&suffix);
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
fn copy_links_to_clipboard(links: &[String]) -> Result<(), String> {
|
||||
if links.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut clipboard =
|
||||
Clipboard::new().map_err(|error| format!("Could not access system clipboard: {error}"))?;
|
||||
|
||||
clipboard
|
||||
.set_text(links.join("\n"))
|
||||
.map_err(|error| format!("Could not copy links to clipboard: {error}"))
|
||||
}
|
||||
|
||||
fn validate_token_permissions(
|
||||
instance_url: &str,
|
||||
token: &str,
|
||||
) -> Result<TokenValidationResult, String> {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|error| format!("Failed to initialize HTTP client: {error}"))?;
|
||||
|
||||
let auth_header = format!("Bearer {token}");
|
||||
|
||||
if token.starts_with("tk_") {
|
||||
let endpoint = format!("{instance_url}/api/v1/browser-extension/validate");
|
||||
let response = client
|
||||
.get(endpoint)
|
||||
.header("Authorization", auth_header)
|
||||
.send()
|
||||
.map_err(|error| format!("Validation request failed: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().unwrap_or_default();
|
||||
return Ok(TokenValidationResult {
|
||||
valid: false,
|
||||
token_type: "api_key".into(),
|
||||
permissions: Vec::new(),
|
||||
message: format!(
|
||||
"Validation failed ({status}): {}",
|
||||
truncate(body.trim(), 160)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let json: Value = response
|
||||
.json()
|
||||
.map_err(|error| format!("Failed to parse validation response: {error}"))?;
|
||||
|
||||
let permissions = json
|
||||
.get("permissions")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str().map(|raw| raw.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
return Ok(TokenValidationResult {
|
||||
valid: json
|
||||
.get("valid")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(true),
|
||||
token_type: "api_key".into(),
|
||||
permissions,
|
||||
message: "API key validated.".into(),
|
||||
});
|
||||
}
|
||||
|
||||
let endpoint = format!("{instance_url}/api/v1/auth/me");
|
||||
let response = client
|
||||
.get(endpoint)
|
||||
.header("Authorization", auth_header)
|
||||
.send()
|
||||
.map_err(|error| format!("Validation request failed: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().unwrap_or_default();
|
||||
return Ok(TokenValidationResult {
|
||||
valid: false,
|
||||
token_type: "jwt".into(),
|
||||
permissions: Vec::new(),
|
||||
message: format!(
|
||||
"Validation failed ({status}): {}",
|
||||
truncate(body.trim(), 160)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(TokenValidationResult {
|
||||
valid: true,
|
||||
token_type: "jwt".into(),
|
||||
permissions: vec!["*".into()],
|
||||
message: "JWT token is valid for this instance.".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn truncate(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
for (index, ch) in value.chars().enumerate() {
|
||||
if index >= max_chars {
|
||||
break;
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.push_str("...");
|
||||
out
|
||||
}
|
||||
|
||||
fn close_window_if_exists<R: Runtime>(app: &AppHandle<R>, label: &str) {
|
||||
if let Some(window) = app.get_webview_window(label) {
|
||||
let _ = window.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn open_setup_window<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
|
||||
if let Some(existing) = app.get_webview_window(SETUP_WINDOW_LABEL) {
|
||||
let _ = existing.show();
|
||||
let _ = existing.set_focus();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
WebviewWindowBuilder::new(
|
||||
app,
|
||||
SETUP_WINDOW_LABEL,
|
||||
WebviewUrl::App("index.html".into()),
|
||||
)
|
||||
.title("Trackeep Desktop - Integrations")
|
||||
.inner_size(860.0, 760.0)
|
||||
.resizable(true)
|
||||
.center()
|
||||
.build()
|
||||
.map_err(|error| format!("Failed to open setup window: {error}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_main_window<R: Runtime>(app: &AppHandle<R>, instance_url: &str) -> Result<(), String> {
|
||||
let parsed = Url::parse(instance_url)
|
||||
.map_err(|error| format!("Saved instance URL is not valid: {error}"))?;
|
||||
|
||||
close_window_if_exists(app, MAIN_WINDOW_LABEL);
|
||||
|
||||
let title_suffix = parsed.host_str().unwrap_or("Trackeep").to_string();
|
||||
|
||||
WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, WebviewUrl::External(parsed))
|
||||
.title(format!("Trackeep Desktop - {title_suffix}"))
|
||||
.inner_size(1440.0, 920.0)
|
||||
.min_inner_size(1024.0, 640.0)
|
||||
.center()
|
||||
.build()
|
||||
.map_err(|error| format!("Failed to open instance window: {error}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_menu<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
|
||||
let desktop_integrations = MenuItem::with_id(
|
||||
app,
|
||||
"desktop_integrations",
|
||||
"Desktop Integrations...",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|error| format!("Failed to create menu item: {error}"))?;
|
||||
|
||||
let upload_files =
|
||||
MenuItem::with_id(app, "upload_files", "Upload Files...", true, None::<&str>)
|
||||
.map_err(|error| format!("Failed to create menu item: {error}"))?;
|
||||
|
||||
let sync_folder_now = MenuItem::with_id(
|
||||
app,
|
||||
"sync_folder_now",
|
||||
"Sync Folder Now",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|error| format!("Failed to create menu item: {error}"))?;
|
||||
|
||||
let quick_share = MenuItem::with_id(
|
||||
app,
|
||||
"quick_share_files",
|
||||
"Quick Share Files...",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|error| format!("Failed to create menu item: {error}"))?;
|
||||
|
||||
let open_sync_folder = MenuItem::with_id(
|
||||
app,
|
||||
"open_sync_folder",
|
||||
"Open Sync Folder",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|error| format!("Failed to create menu item: {error}"))?;
|
||||
|
||||
let reload_instance = MenuItem::with_id(
|
||||
app,
|
||||
"reload_instance",
|
||||
"Reload Instance",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|error| format!("Failed to create menu item: {error}"))?;
|
||||
|
||||
let quit_app = MenuItem::with_id(app, "quit_app", "Quit", true, None::<&str>)
|
||||
.map_err(|error| format!("Failed to create menu item: {error}"))?;
|
||||
|
||||
let trackeep_menu = Submenu::with_id_and_items(
|
||||
app,
|
||||
"trackeep",
|
||||
"Trackeep",
|
||||
true,
|
||||
&[
|
||||
&desktop_integrations,
|
||||
&upload_files,
|
||||
&quick_share,
|
||||
&sync_folder_now,
|
||||
&open_sync_folder,
|
||||
&reload_instance,
|
||||
&quit_app,
|
||||
],
|
||||
)
|
||||
.map_err(|error| format!("Failed to create submenu: {error}"))?;
|
||||
|
||||
let menu = Menu::with_items(app, &[&trackeep_menu])
|
||||
.map_err(|error| format!("Failed to create app menu: {error}"))?;
|
||||
|
||||
app.set_menu(menu)
|
||||
.map_err(|error| format!("Failed to attach app menu: {error}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_summary_dialog(title: &str, summary: &UploadSummary) {
|
||||
let mut details = format!(
|
||||
"Uploaded: {}\nShared links: {}\nFailed: {}",
|
||||
summary.uploaded, summary.shared, summary.failed
|
||||
);
|
||||
|
||||
if summary.clipboard_copied && !summary.shared_links.is_empty() {
|
||||
details.push_str("\n\nShare links were copied to clipboard.");
|
||||
}
|
||||
|
||||
if !summary.shared_links.is_empty() {
|
||||
details.push_str("\n\nLinks:\n");
|
||||
for link in summary.shared_links.iter().take(5) {
|
||||
details.push_str("- ");
|
||||
details.push_str(link);
|
||||
details.push('\n');
|
||||
}
|
||||
if summary.shared_links.len() > 5 {
|
||||
details.push_str("- ...\n");
|
||||
}
|
||||
}
|
||||
|
||||
if summary.failed > 0 {
|
||||
details.push_str("\n\nErrors:\n");
|
||||
for failure in summary.failures.iter().take(5) {
|
||||
details.push_str("- ");
|
||||
details.push_str(failure);
|
||||
details.push('\n');
|
||||
}
|
||||
if summary.failures.len() > 5 {
|
||||
details.push_str("- ...");
|
||||
}
|
||||
}
|
||||
|
||||
MessageDialog::new()
|
||||
.set_level(if summary.failed > 0 {
|
||||
MessageLevel::Warning
|
||||
} else {
|
||||
MessageLevel::Info
|
||||
})
|
||||
.set_title(title)
|
||||
.set_description(details)
|
||||
.set_buttons(MessageButtons::Ok)
|
||||
.show();
|
||||
}
|
||||
|
||||
fn show_error_dialog(title: &str, error: &str) {
|
||||
MessageDialog::new()
|
||||
.set_level(MessageLevel::Error)
|
||||
.set_title(title)
|
||||
.set_description(error)
|
||||
.set_buttons(MessageButtons::Ok)
|
||||
.show();
|
||||
}
|
||||
|
||||
fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, menu_id: &str) {
|
||||
match menu_id {
|
||||
"desktop_integrations" => {
|
||||
if let Err(error) = open_setup_window(app) {
|
||||
show_error_dialog("Trackeep Desktop", &error);
|
||||
}
|
||||
}
|
||||
"upload_files" => match upload_files_now(app.clone()) {
|
||||
Ok(summary) => show_summary_dialog("Trackeep Desktop Upload", &summary),
|
||||
Err(error) => show_error_dialog("Trackeep Desktop Upload", &error),
|
||||
},
|
||||
"sync_folder_now" => match sync_folder_now(app.clone()) {
|
||||
Ok(summary) => show_summary_dialog("Trackeep Desktop Sync", &summary),
|
||||
Err(error) => show_error_dialog("Trackeep Desktop Sync", &error),
|
||||
},
|
||||
"quick_share_files" => match quick_share_files(app.clone()) {
|
||||
Ok(summary) => show_summary_dialog("Trackeep Desktop Quick Share", &summary),
|
||||
Err(error) => show_error_dialog("Trackeep Desktop Quick Share", &error),
|
||||
},
|
||||
"open_sync_folder" => {
|
||||
if let Err(error) = open_sync_folder(app.clone()) {
|
||||
show_error_dialog("Trackeep Desktop", &error);
|
||||
}
|
||||
}
|
||||
"reload_instance" => {
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
let _ = window.eval("window.location.reload();");
|
||||
}
|
||||
}
|
||||
"quit_app" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed_error(message: String) -> Box<dyn std::error::Error> {
|
||||
Box::new(io::Error::new(io::ErrorKind::Other, message))
|
||||
}
|
||||
|
||||
fn path_to_string(path: &Path) -> String {
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_desktop_config,
|
||||
connect_instance,
|
||||
select_sync_folder,
|
||||
open_sync_folder,
|
||||
upload_files_now,
|
||||
sync_folder_now,
|
||||
quick_share_files,
|
||||
validate_integration_token
|
||||
])
|
||||
.on_menu_event(|app, event| {
|
||||
handle_menu_event(app, event.id().as_ref());
|
||||
})
|
||||
.setup(|app| {
|
||||
setup_menu(&app.handle()).map_err(boxed_error)?;
|
||||
|
||||
match load_config(&app.handle())
|
||||
.map_err(boxed_error)?
|
||||
.instance_url
|
||||
.as_deref()
|
||||
.map(normalize_instance_url)
|
||||
{
|
||||
Some(Ok(instance_url)) => {
|
||||
open_main_window(&app.handle(), &instance_url).map_err(boxed_error)?;
|
||||
}
|
||||
Some(Err(_)) | None => {
|
||||
open_setup_window(&app.handle()).map_err(boxed_error)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Trackeep desktop");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Trackeep Desktop",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.trackeep.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/icon.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
const form = document.getElementById('connect-form');
|
||||
const instanceInput = document.getElementById('instance-url');
|
||||
const apiKeyInput = document.getElementById('api-key');
|
||||
const syncFolderInput = document.getElementById('sync-folder');
|
||||
const permissionStatusEl = document.getElementById('permission-status');
|
||||
|
||||
const connectButton = document.getElementById('connect-button');
|
||||
const validateTokenButton = document.getElementById('validate-token');
|
||||
const chooseSyncFolderButton = document.getElementById('choose-sync-folder');
|
||||
const openSyncFolderButton = document.getElementById('open-sync-folder');
|
||||
const uploadNowButton = document.getElementById('upload-now');
|
||||
const quickShareNowButton = document.getElementById('quick-share-now');
|
||||
const syncNowButton = document.getElementById('sync-now');
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
const errorEl = document.getElementById('error');
|
||||
|
||||
const actionableButtons = [
|
||||
connectButton,
|
||||
validateTokenButton,
|
||||
chooseSyncFolderButton,
|
||||
openSyncFolderButton,
|
||||
uploadNowButton,
|
||||
quickShareNowButton,
|
||||
syncNowButton,
|
||||
];
|
||||
|
||||
const setStatus = (message = '') => {
|
||||
statusEl.textContent = message;
|
||||
};
|
||||
|
||||
const setError = (message = '') => {
|
||||
errorEl.textContent = message;
|
||||
};
|
||||
|
||||
const setPermissionStatus = (message = '') => {
|
||||
permissionStatusEl.textContent = message;
|
||||
};
|
||||
|
||||
const setBusy = (busy, connectLabel = 'Save and Open Instance') => {
|
||||
actionableButtons.forEach((button) => {
|
||||
button.disabled = busy;
|
||||
});
|
||||
instanceInput.disabled = busy;
|
||||
apiKeyInput.disabled = busy;
|
||||
connectButton.textContent = busy ? connectLabel : 'Save and Open Instance';
|
||||
};
|
||||
|
||||
const isValidUrl = (value) => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatSummary = (summary) => {
|
||||
if (!summary) {
|
||||
return 'No response from desktop command.';
|
||||
}
|
||||
|
||||
const uploaded = Number(summary.uploaded ?? 0);
|
||||
const shared = Number(summary.shared ?? 0);
|
||||
const failed = Number(summary.failed ?? 0);
|
||||
|
||||
if (uploaded === 0 && shared === 0 && failed === 0) {
|
||||
return 'No files selected.';
|
||||
}
|
||||
|
||||
const copiedSuffix = summary.clipboard_copied ? ' Links copied to clipboard.' : '';
|
||||
|
||||
if (failed === 0) {
|
||||
return `Uploaded ${uploaded} file(s), shared ${shared}.${copiedSuffix}`.trim();
|
||||
}
|
||||
|
||||
const firstFailure = Array.isArray(summary.failures) && summary.failures.length > 0
|
||||
? ` First error: ${summary.failures[0]}`
|
||||
: '';
|
||||
|
||||
return `Uploaded ${uploaded}, shared ${shared}, failed ${failed}.${copiedSuffix}${firstFailure}`.trim();
|
||||
};
|
||||
|
||||
const hydrate = async () => {
|
||||
setBusy(true, 'Loading...');
|
||||
setStatus('Loading desktop configuration...');
|
||||
setError('');
|
||||
setPermissionStatus('');
|
||||
|
||||
try {
|
||||
const config = await invoke('get_desktop_config');
|
||||
|
||||
instanceInput.value = config.instance_url || '';
|
||||
apiKeyInput.value = config.api_key || '';
|
||||
syncFolderInput.value = config.sync_folder || '';
|
||||
|
||||
if (config.instance_url) {
|
||||
setStatus(`Saved instance: ${config.instance_url}`);
|
||||
} else {
|
||||
setStatus('No instance configured yet.');
|
||||
}
|
||||
|
||||
if (config.api_key) {
|
||||
setPermissionStatus('Saved token is present. Click "Validate Permissions" to verify access.');
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setError(`Could not load desktop config: ${message}`);
|
||||
setStatus('');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
|
||||
const instanceUrl = instanceInput.value.trim();
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
const syncFolder = syncFolderInput.value.trim();
|
||||
|
||||
if (!isValidUrl(instanceUrl)) {
|
||||
setError('Enter a valid http(s) URL, for example https://trackeep.your-domain.com');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true, 'Saving...');
|
||||
setStatus(`Saving configuration for ${instanceUrl} ...`);
|
||||
|
||||
try {
|
||||
await invoke('connect_instance', {
|
||||
instanceUrl,
|
||||
apiKey: apiKey || null,
|
||||
syncFolder: syncFolder || null,
|
||||
});
|
||||
|
||||
setStatus('Connected. Opening your Trackeep instance...');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setError(message);
|
||||
setStatus('');
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
validateTokenButton.addEventListener('click', async () => {
|
||||
setError('');
|
||||
|
||||
const instanceUrl = instanceInput.value.trim();
|
||||
const token = apiKeyInput.value.trim();
|
||||
|
||||
if (!isValidUrl(instanceUrl)) {
|
||||
setError('Set a valid instance URL before token validation.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
setError('Enter API key/token before validation.');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true, 'Validating...');
|
||||
|
||||
try {
|
||||
const result = await invoke('validate_integration_token', { instanceUrl, token });
|
||||
if (result.valid) {
|
||||
const perms = Array.isArray(result.permissions) && result.permissions.length > 0
|
||||
? result.permissions.join(', ')
|
||||
: 'none reported';
|
||||
setPermissionStatus(`Token valid (${result.token_type}). Permissions: ${perms}`);
|
||||
setStatus('Token validation successful.');
|
||||
} else {
|
||||
setPermissionStatus('Token validation failed.');
|
||||
setError(result.message || 'Token is not valid for this instance.');
|
||||
setStatus('');
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setError(message);
|
||||
setStatus('');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
chooseSyncFolderButton.addEventListener('click', async () => {
|
||||
setError('');
|
||||
setBusy(true, 'Working...');
|
||||
|
||||
try {
|
||||
const selected = await invoke('select_sync_folder');
|
||||
if (selected) {
|
||||
syncFolderInput.value = selected;
|
||||
setStatus(`Sync folder selected: ${selected}`);
|
||||
} else {
|
||||
setStatus('Sync folder selection canceled.');
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setError(message);
|
||||
setStatus('');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
openSyncFolderButton.addEventListener('click', async () => {
|
||||
setError('');
|
||||
setBusy(true, 'Opening...');
|
||||
|
||||
try {
|
||||
await invoke('open_sync_folder');
|
||||
setStatus('Opened sync folder.');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setError(message);
|
||||
setStatus('');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
uploadNowButton.addEventListener('click', async () => {
|
||||
setError('');
|
||||
setBusy(true, 'Uploading...');
|
||||
|
||||
try {
|
||||
const summary = await invoke('upload_files_now');
|
||||
setStatus(formatSummary(summary));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setError(message);
|
||||
setStatus('');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
quickShareNowButton.addEventListener('click', async () => {
|
||||
setError('');
|
||||
setBusy(true, 'Sharing...');
|
||||
|
||||
try {
|
||||
const summary = await invoke('quick_share_files');
|
||||
setStatus(formatSummary(summary));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setError(message);
|
||||
setStatus('');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
syncNowButton.addEventListener('click', async () => {
|
||||
setError('');
|
||||
setBusy(true, 'Syncing...');
|
||||
|
||||
try {
|
||||
const summary = await invoke('sync_folder_now');
|
||||
setStatus(formatSummary(summary));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setError(message);
|
||||
setStatus('');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
void hydrate();
|
||||
@@ -0,0 +1,136 @@
|
||||
:root {
|
||||
font-family: "Inter", "Segoe UI", sans-serif;
|
||||
color: #0f172a;
|
||||
background: radial-gradient(circle at top right, #f0f9ff, #e2e8f0 45%, #cbd5e1 100%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(900px, 100%);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.16);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid #94a3b8;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #f8fafc;
|
||||
background: #0f172a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.native-actions {
|
||||
border-top: 1px solid #cbd5e1;
|
||||
padding-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #475569;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 1px solid #cbd5e1;
|
||||
padding-top: 12px;
|
||||
color: #334155;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "JetBrains Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
},
|
||||
preview: {
|
||||
host: '127.0.0.1',
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user