added license, readme and theme management

This commit is contained in:
xyroscar
2025-11-28 03:52:30 -08:00
parent 0f6d7c052b
commit 57cacd8918
12 changed files with 499 additions and 83 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Xyroscar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,7 +1,67 @@
# Tauri + SvelteKit + TypeScript
# Resona
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite.
Resona is a desktop API client for testing and debugging HTTP APIs. Built with Tauri, SvelteKit, and Rust.
## Recommended IDE Setup
## Features
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
- **Workspaces**: Organize your API requests into workspaces with tags for easy filtering
- **Collections**: Group related requests into collections within workspaces
- **Variables**: Define variables at global, workspace, collection, or request scope with automatic interpolation
- **HTTP Client**: Send HTTP requests with support for various body types (JSON, form-data, URL-encoded, etc.)
- **Sync Groups**: Sync variables across multiple workspaces
- **Themes**: Multiple built-in themes including light, dark, and Catppuccin variants (Latte, Frappe, Macchiato, Mocha)
- **Persistent Storage**: All data is stored locally using an embedded database (redb)
## Tech Stack
- **Frontend**: SvelteKit, TypeScript, TailwindCSS, shadcn-svelte
- **Backend**: Rust, Tauri
- **Database**: redb (embedded key-value store)
- **HTTP**: reqwest
## Development
### Prerequisites
- Node.js (v18+)
- Rust (latest stable)
- Bun (or npm/yarn/pnpm)
### Setup
```bash
# Install dependencies
bun install
# Run in development mode
bun run tauri dev
# Build for production
bun run tauri build
```
### Project Structure
```
resona/
├── src/ # Frontend (SvelteKit)
│ ├── lib/
│ │ ├── components/ # UI components
│ │ ├── services/ # API service layer
│ │ └── types/ # TypeScript types
│ └── routes/ # SvelteKit routes
├── src-tauri/ # Backend (Rust/Tauri)
│ └── src/
│ ├── collections/ # Collections module
│ ├── db/ # Database layer
│ ├── http/ # HTTP client
│ ├── requests/ # Requests module
│ ├── settings/ # App settings
│ ├── variables/ # Variables module
│ └── workspaces/ # Workspaces module
└── static/ # Static assets
```
## License
MIT License - see [LICENSE](LICENSE) for details.

View File

@@ -25,6 +25,9 @@ pub enum DbError {
#[error("Serialization error: {0}")]
Serialization(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}

View File

@@ -4,6 +4,7 @@ mod collections;
mod db;
mod http;
mod requests;
mod settings;
mod variables;
mod workspaces;
@@ -18,6 +19,7 @@ use requests::{
create_request, delete_request, get_all_requests_by_workspace, get_request,
get_requests_by_collection, get_standalone_requests_by_workspace, update_request,
};
use settings::{get_settings, reset_settings, update_settings};
use variables::{
create_variable, delete_variable, get_collection_variables, get_global_variables,
get_request_variables, get_resolved_variables, get_variable, get_workspace_variables,
@@ -86,6 +88,10 @@ pub fn run() {
delete_variable,
// HTTP client
send_http_request,
// Settings commands
get_settings,
update_settings,
reset_settings,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -0,0 +1,27 @@
use tauri::State;
use crate::db::Database;
use super::service::SettingsService;
use super::types::{AppSettings, UpdateSettingsInput};
#[tauri::command]
pub fn get_settings(db: State<Database>) -> Result<AppSettings, String> {
let service = SettingsService::new(db.inner().clone());
service.get().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_settings(
db: State<Database>,
input: UpdateSettingsInput,
) -> Result<AppSettings, String> {
let service = SettingsService::new(db.inner().clone());
service.update(input).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn reset_settings(db: State<Database>) -> Result<AppSettings, String> {
let service = SettingsService::new(db.inner().clone());
service.reset().map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,7 @@
mod commands;
mod service;
mod types;
pub use commands::*;
#[allow(unused_imports)]
pub use types::{AppSettings, CustomTheme, Theme, ThemeColors, UpdateSettingsInput};

View File

@@ -0,0 +1,78 @@
use crate::db::{Database, DbResult, APP_SETTINGS};
use super::types::{AppSettings, UpdateSettingsInput};
const SETTINGS_KEY: &str = "settings";
pub struct SettingsService {
db: Database,
}
impl SettingsService {
pub fn new(db: Database) -> Self {
Self { db }
}
pub fn get(&self) -> DbResult<AppSettings> {
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(APP_SETTINGS)?;
match table.get(SETTINGS_KEY)? {
Some(value) => {
let settings: AppSettings = serde_json::from_str(value.value())?;
Ok(settings)
}
None => Ok(AppSettings::default()),
}
}
pub fn update(&self, input: UpdateSettingsInput) -> DbResult<AppSettings> {
let mut settings = self.get()?;
if let Some(theme) = input.theme {
settings.theme = theme;
}
if let Some(custom_themes) = input.custom_themes {
settings.custom_themes = custom_themes;
}
if let Some(default_timeout) = input.default_timeout {
settings.default_timeout = default_timeout;
}
if let Some(follow_redirects) = input.follow_redirects {
settings.follow_redirects = follow_redirects;
}
if let Some(validate_ssl) = input.validate_ssl {
settings.validate_ssl = validate_ssl;
}
if let Some(max_history_items) = input.max_history_items {
settings.max_history_items = max_history_items;
}
if let Some(auto_save_requests) = input.auto_save_requests {
settings.auto_save_requests = auto_save_requests;
}
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(APP_SETTINGS)?;
let json = serde_json::to_string(&settings)?;
table.insert(SETTINGS_KEY, json.as_str())?;
}
write_txn.commit()?;
Ok(settings)
}
pub fn reset(&self) -> DbResult<AppSettings> {
let settings = AppSettings::default();
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(APP_SETTINGS)?;
let json = serde_json::to_string(&settings)?;
table.insert(SETTINGS_KEY, json.as_str())?;
}
write_txn.commit()?;
Ok(settings)
}
}

View File

@@ -0,0 +1,95 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
System,
Light,
Dark,
Latte,
Frappe,
Macchiato,
Mocha,
Custom,
}
impl Default for Theme {
fn default() -> Self {
Self::System
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomTheme {
pub name: String,
pub colors: ThemeColors,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ThemeColors {
pub background: String,
pub foreground: String,
pub primary: String,
pub secondary: String,
pub accent: String,
pub muted: String,
pub border: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
pub theme: Theme,
#[serde(default)]
pub custom_themes: Vec<CustomTheme>,
#[serde(default = "default_timeout")]
pub default_timeout: u32,
#[serde(default = "default_true")]
pub follow_redirects: bool,
#[serde(default = "default_true")]
pub validate_ssl: bool,
#[serde(default = "default_max_history")]
pub max_history_items: u32,
#[serde(default = "default_true")]
pub auto_save_requests: bool,
}
fn default_timeout() -> u32 {
30000
}
fn default_true() -> bool {
true
}
fn default_max_history() -> u32 {
100
}
impl Default for AppSettings {
fn default() -> Self {
Self {
theme: Theme::System,
custom_themes: Vec::new(),
default_timeout: 30000,
follow_redirects: true,
validate_ssl: true,
max_history_items: 100,
auto_save_requests: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSettingsInput {
pub theme: Option<Theme>,
pub custom_themes: Option<Vec<CustomTheme>>,
pub default_timeout: Option<u32>,
pub follow_redirects: Option<bool>,
pub validate_ssl: Option<bool>,
pub max_history_items: Option<u32>,
pub auto_save_requests: Option<bool>,
}

View File

@@ -7,12 +7,18 @@
import { Label } from "$lib/components/ui/label/index.js";
import { Separator } from "$lib/components/ui/separator/index.js";
import SettingsIcon from "@lucide/svelte/icons/settings";
import type { AppSettings } from "$lib/types/workspace";
import CheckIcon from "@lucide/svelte/icons/check";
import type { AppSettings, Theme } from "$lib/types/workspace";
import {
get_settings,
update_settings,
reset_settings,
} from "$lib/services/settings";
import {
theme as themeStore,
themes,
themeLabels,
} from "$lib/theme-switcher";
import { onMount } from "svelte";
type Props = {
@@ -24,9 +30,10 @@
let settings = $state<AppSettings>({
theme: "system",
customThemes: [],
defaultTimeout: 30000,
followRedirects: true,
validateSSL: true,
validateSsl: true,
maxHistoryItems: 100,
autoSaveRequests: true,
});
@@ -56,10 +63,12 @@
async function handleReset() {
settings = await reset_settings();
themeStore.applyTheme(settings.theme);
}
function handleThemeChange(value: string) {
settings.theme = value as "light" | "dark" | "system";
async function handleThemeChange(newTheme: Theme) {
settings.theme = newTheme;
await themeStore.applyTheme(newTheme);
}
</script>
@@ -77,42 +86,69 @@
</Dialog.Description>
</Dialog.Header>
<Tabs.Root value="general" class="flex-1 overflow-hidden">
<Tabs.List class="grid w-full grid-cols-3">
<Tabs.Root value="appearance" class="flex-1 overflow-hidden">
<Tabs.List class="grid w-full grid-cols-4">
<Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="requests">Requests</Tabs.Trigger>
<Tabs.Trigger value="advanced">Advanced</Tabs.Trigger>
</Tabs.List>
<div class="mt-4 overflow-auto max-h-[400px]">
<Tabs.Content value="general" class="space-y-4">
<div class="space-y-2">
<Label for="theme">Theme</Label>
<Select.Root
type="single"
value={settings.theme}
onValueChange={handleThemeChange}
>
<Select.Trigger id="theme" class="w-full">
{settings.theme === "light"
? "Light"
: settings.theme === "dark"
? "Dark"
: "System"}
</Select.Trigger>
<Select.Content>
<Select.Item value="system">System</Select.Item>
<Select.Item value="light">Light</Select.Item>
<Select.Item value="dark">Dark</Select.Item>
</Select.Content>
</Select.Root>
<Tabs.Content value="appearance" class="space-y-4">
<div class="space-y-3">
<Label>Theme</Label>
<p class="text-xs text-muted-foreground">
Choose the application color theme.
Select a color theme for the application.
</p>
<div class="grid grid-cols-2 gap-2">
{#each themes as t}
<button
type="button"
class="flex items-center justify-between px-3 py-2 rounded-md border text-sm transition-colors {settings.theme ===
t
? 'border-primary bg-primary/10'
: 'border-border hover:bg-muted'}"
onclick={() => handleThemeChange(t)}
>
<span>{themeLabels[t]}</span>
{#if settings.theme === t}
<CheckIcon class="size-4 text-primary" />
{/if}
</button>
{/each}
</div>
</div>
<Separator />
<div class="rounded-lg border p-4 bg-muted/30">
<h4 class="font-medium mb-2">Theme Preview</h4>
<p class="text-sm text-muted-foreground mb-3">
Preview of the current theme colors.
</p>
<div class="flex gap-2">
<div
class="size-8 rounded bg-background border"
title="Background"
></div>
<div
class="size-8 rounded bg-foreground"
title="Foreground"
></div>
<div class="size-8 rounded bg-primary" title="Primary"></div>
<div class="size-8 rounded bg-secondary" title="Secondary"></div>
<div class="size-8 rounded bg-muted" title="Muted"></div>
<div class="size-8 rounded bg-accent" title="Accent"></div>
<div
class="size-8 rounded bg-destructive"
title="Destructive"
></div>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="general" class="space-y-4">
<div class="space-y-2">
<Label for="maxHistory">History Items</Label>
<Input
@@ -127,6 +163,8 @@
</p>
</div>
<Separator />
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Auto-save Requests</Label>
@@ -208,15 +246,15 @@
<button
type="button"
role="switch"
aria-checked={settings.validateSSL}
aria-checked={settings.validateSsl}
aria-label="Toggle SSL validation"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.validateSSL
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.validateSsl
? 'bg-primary'
: 'bg-input'}"
onclick={() => (settings.validateSSL = !settings.validateSSL)}
onclick={() => (settings.validateSsl = !settings.validateSsl)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.validateSSL
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.validateSsl
? 'translate-x-5'
: 'translate-x-0'}"
></span>

View File

@@ -1,33 +1,16 @@
import type { AppSettings } from "$lib/types/workspace";
let settings: AppSettings = {
theme: "system",
defaultTimeout: 30000,
followRedirects: true,
validateSSL: true,
maxHistoryItems: 100,
autoSaveRequests: true,
};
import { invoke } from "@tauri-apps/api/core";
import type { AppSettings, UpdateSettingsInput } from "$lib/types/workspace";
export async function get_settings(): Promise<AppSettings> {
return { ...settings };
return invoke<AppSettings>("get_settings");
}
export async function update_settings(
updates: Partial<AppSettings>
input: UpdateSettingsInput
): Promise<AppSettings> {
settings = { ...settings, ...updates };
return { ...settings };
return invoke<AppSettings>("update_settings", { input });
}
export async function reset_settings(): Promise<AppSettings> {
settings = {
theme: "system",
defaultTimeout: 30000,
followRedirects: true,
validateSSL: true,
maxHistoryItems: 100,
autoSaveRequests: true,
};
return { ...settings };
return invoke<AppSettings>("reset_settings");
}

View File

@@ -1,7 +1,10 @@
import { writable } from "svelte/store";
import { browser } from "$app/environment";
import { get_settings, update_settings } from "$lib/services/settings";
import type { Theme } from "$lib/types/workspace";
export const themes = [
"system",
"light",
"dark",
"latte",
@@ -9,46 +12,105 @@ export const themes = [
"macchiato",
"mocha",
] as const;
type Theme = (typeof themes)[number];
export const themeLabels: Record<Theme, string> = {
system: "System",
light: "Light",
dark: "Dark",
latte: "Latte (Catppuccin)",
frappe: "Frappe (Catppuccin)",
macchiato: "Macchiato (Catppuccin)",
mocha: "Mocha (Catppuccin)",
custom: "Custom",
};
const createThemeStore = () => {
const { subscribe, set } = writable<Theme>("light");
const { subscribe, set } = writable<Theme>("system");
function applyTheme(theme: Theme) {
function getSystemTheme(): "light" | "dark" {
if (browser) {
document.documentElement.classList.remove(...themes);
document.documentElement.classList.add(theme);
localStorage.setItem("theme", theme);
set(theme);
}
}
function initializeTheme() {
if (browser) {
const storedTheme = localStorage.getItem("theme") as Theme | null;
const systemTheme: Theme = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
const initialTheme = storedTheme ?? systemTheme;
applyTheme(initialTheme);
}
return "light";
}
function resetToSystem() {
function applyThemeToDOM(theme: Theme) {
if (browser) {
localStorage.removeItem("theme");
initializeTheme();
const allThemes = [
"light",
"dark",
"latte",
"frappe",
"macchiato",
"mocha",
];
document.documentElement.classList.remove(...allThemes);
const effectiveTheme = theme === "system" ? getSystemTheme() : theme;
if (effectiveTheme !== "custom") {
document.documentElement.classList.add(effectiveTheme);
}
}
}
return {
async function applyTheme(theme: Theme) {
applyThemeToDOM(theme);
set(theme);
try {
await update_settings({ theme });
} catch (e) {
console.error("Failed to save theme setting:", e);
}
}
async function initializeTheme() {
if (browser) {
try {
const settings = await get_settings();
const theme = settings.theme;
applyThemeToDOM(theme);
set(theme);
} catch (e) {
console.error("Failed to load theme setting:", e);
applyThemeToDOM("system");
set("system");
}
// Listen for system theme changes
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
// Re-apply if using system theme
const currentTheme = get(themeStore);
if (currentTheme === "system") {
applyThemeToDOM("system");
}
});
}
}
async function resetToSystem() {
await applyTheme("system");
}
const themeStore = {
subscribe,
applyTheme,
initializeTheme,
resetToSystem,
};
return themeStore;
};
function get<T>(store: {
subscribe: (fn: (value: T) => void) => () => void;
}): T {
let value: T;
store.subscribe((v) => (value = v))();
return value!;
}
export const theme = createThemeStore();

View File

@@ -46,11 +46,47 @@ export type UpdateSyncGroupInput = {
sync_secrets?: boolean;
};
export type Theme =
| "system"
| "light"
| "dark"
| "latte"
| "frappe"
| "macchiato"
| "mocha"
| "custom";
export type ThemeColors = {
background: string;
foreground: string;
primary: string;
secondary: string;
accent: string;
muted: string;
border: string;
};
export type CustomTheme = {
name: string;
colors: ThemeColors;
};
export type AppSettings = {
theme: "light" | "dark" | "system";
theme: Theme;
customThemes: CustomTheme[];
defaultTimeout: number;
followRedirects: boolean;
validateSSL: boolean;
validateSsl: boolean;
maxHistoryItems: number;
autoSaveRequests: boolean;
};
export type UpdateSettingsInput = {
theme?: Theme;
customThemes?: CustomTheme[];
defaultTimeout?: number;
followRedirects?: boolean;
validateSsl?: boolean;
maxHistoryItems?: number;
autoSaveRequests?: boolean;
};