Compare commits

..

24 commits

Author SHA1 Message Date
Vomitblood b1da210a9f migrate to tauri 2.0 2024-10-22 22:10:01 +08:00
Vomitblood 9036e5f189 moved color settings to dedicated tab 2024-08-23 17:16:48 +08:00
Vomitblood 063b27a7eb fixed settings panel showing default settings on load 2024-08-12 20:55:26 +08:00
Vomitblood 166b90444d changed ts compiler config 2024-08-10 19:13:05 +08:00
Vomitblood 79f7cbc759 changed backend package manager to bun 2024-08-10 04:33:54 +08:00
Vomitblood 2e261a8f10 added fullscreen window button 2024-08-10 04:27:37 +08:00
Vomitblood f237031eb3 added debug panel 2024-08-10 03:14:23 +08:00
Vomitblood e48611b11c style improvements 2024-08-10 02:43:41 +08:00
Vomitblood 72f9121593 improvements to background setting logic 2024-08-09 21:11:12 +08:00
Vomitblood 2337661fed improvements to background setting logic 2024-08-09 18:58:19 +08:00
Vomitblood 205b0e593c moved initialization logic to rust backend 2024-08-08 19:56:46 +08:00
Vomitblood a379f74616 connected rust wallpaper functions with frontend 2024-08-08 11:56:54 +08:00
Vomitblood f8ac26c51d fixed build script to properly install rust 2024-08-08 04:05:42 +08:00
Vomitblood 8f184cb72f added copy wallpaper functionality 2024-08-08 03:52:19 +08:00
Vomitblood 69332f1655 added process wallpaper image tauri function 2024-08-08 03:09:31 +08:00
Vomitblood aafa0b9430 rust backend for processing wallpaper images 2024-08-08 02:35:06 +08:00
Vomitblood 3b7c771c85 updated github actions workflow 2024-08-08 02:10:59 +08:00
Vomitblood fb2b7b0bf0 changed github actions workflow to install rustup with defaults accepted 2024-08-08 02:10:36 +08:00
Vomitblood 3e813bbe7e changed github actions workflow to install rustup 2024-08-08 00:45:20 +08:00
Vomitblood 8cc4f0bcbc updated github actions workflow 2024-08-08 00:35:49 +08:00
Vomitblood de471c3f73 added rust backend webp animation checker 2024-08-08 00:31:49 +08:00
Vomitblood 6e006d55f9 updated github actions workflow 2024-08-07 22:17:58 +08:00
Vomitblood 2764e6c2f0 fixed bug csp causing render issues 2024-08-07 18:59:11 +08:00
Vomitblood 7c36a66103 added back github actions workflow
updated github actions workflow
2024-08-07 18:28:45 +08:00
56 changed files with 20046 additions and 1721 deletions

View file

@ -1,4 +1,4 @@
name: Test Build Frontend name: Build
on: on:
push: push:
@ -15,24 +15,21 @@ jobs:
image: ubuntu:22.04 image: ubuntu:22.04
steps: steps:
- name: Install git - name: Update APT
run: apt update && apt install -y git run: apt update
- name: Install Git and dependencies for Tauri
run: apt install -y git libwebkit2gtk-4.0-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Get Variables - name: Get Variables
run: | run: |
git config --global --add safe.directory /__w/stort/stort git config --global --add safe.directory /__w/stort/stort
echo "TAG_NAME=commit-$(git rev-parse --short HEAD)" >> $GITHUB_ENV echo "TAG_NAME=commit-$(date +%Y%m%d)-$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Update packages - name: Install Mise
run: apt update && apt upgrade -y
- name: Install dependencies for Tauri
run: apt install -y cargo libwebkit2gtk-4.0-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
- name: Install mise
run: | run: |
install -dm 755 /etc/apt/keyrings install -dm 755 /etc/apt/keyrings
wget -qO - https://mise.jdx.dev/gpg-key.pub | gpg --dearmor | tee /etc/apt/keyrings/mise-archive-keyring.gpg 1> /dev/null wget -qO - https://mise.jdx.dev/gpg-key.pub | gpg --dearmor | tee /etc/apt/keyrings/mise-archive-keyring.gpg 1> /dev/null
@ -40,16 +37,19 @@ jobs:
apt update apt update
apt install -y mise apt install -y mise
- name: Install mise tools - name: Install Mise Tools
run: | run: |
mise install mise install
mise settings set experimental true mise settings set experimental true
- name: Install bun packages - name: Install bun Packages
run: mise exec bun --command 'bun install' run: mise exec bun --command 'bun install'
- name: Build Tauri - name: Build Tauri
run: mise exec bun --command 'bun run tauri build' run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
. "$HOME/.cargo/env"
mise exec bun --command 'bun run tauri build'
- name: Create Release - name: Create Release
id: create_release id: create_release
@ -62,12 +62,12 @@ jobs:
draft: false draft: false
prerelease: false prerelease: false
- name: Upload whatsip artifact to release (Windows) - name: Upload Build to Release
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./src-tauri/target/release/bundle/appimage/*.AppImage asset_path: ./src-tauri/target/release/bundle/appimage/stort_0.1.0_amd64.AppImage
asset_name: stort.AppImage asset_name: stort.AppImage
asset_content_type: application/octet-stream asset_content_type: application/octet-stream

View file

@ -1,2 +1,2 @@
bun 1.1.21 bun 1.1.22
nodejs 20.6.1 nodejs 20.6.1

2
README
View file

@ -1,2 +0,0 @@
[![Test Build Frontend](https://github.com/Vomitblood/stort/actions/workflows/build-validation.yml/badge.svg)](https://github.com/Vomitblood/stort/actions/workflows/build-validation.yml)
A launcher for Steam Deck to be used in Game Mode.

4
README.md Normal file
View file

@ -0,0 +1,4 @@
# Stort
[![Build](https://github.com/Vomitblood/stort/actions/workflows/build.yml/badge.svg)](https://github.com/Vomitblood/stort/actions/workflows/build.yml)
A launcher for Steam Deck to be used in Game Mode.

View file

@ -20,7 +20,11 @@ docker run --rm -it \
apt update && apt upgrade -y && \ apt update && apt upgrade -y && \
# install tauri dependencies # install tauri dependencies
apt install -y cargo libwebkit2gtk-4.0-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev && \ apt install -y libwebkit2gtk-4.0-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev && \
# install rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
. /root/.cargo/env && \
# install mise # install mise
install -dm 755 /etc/apt/keyrings && \ install -dm 755 /etc/apt/keyrings && \

BIN
bun.lockb

Binary file not shown.

7033
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,20 +15,25 @@
"@emotion/server": "^11.11.0", "@emotion/server": "^11.11.0",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@mui/icons-material": "^5.16.6", "@mui/icons-material": "^5.16.6",
"@mui/lab": "5.0.0-alpha.143", "@mui/lab": "^5.0.0-alpha.173",
"@mui/material": "^5.16.6", "@mui/material": "^5.16.6",
"@tauri-apps/api": "^1.6.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.1",
"@tauri-apps/plugin-fs": "^2.0.1",
"@tauri-apps/plugin-notification": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"jotai": "^2.9.1", "jotai": "^2.9.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"next": "14.2.5", "next": "14.2.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"smol-toml": "^1.3.0", "smol-toml": "^1.3.0",
"tauri": "^0.15.0",
"zustand": "^4.5.4" "zustand": "^4.5.4"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.0.4",
"@types/lodash": "^4.17.7", "@types/lodash": "^4.17.7",
"@tauri-apps/cli": "^1.6.0",
"@types/node": "^20.14.14", "@types/node": "^20.14.14",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
@ -36,4 +41,4 @@
"eslint-config-next": "14.2.5", "eslint-config-next": "14.2.5",
"typescript": "^5.5.4" "typescript": "^5.5.4"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 MiB

BIN
public/images/cry.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

Before

Width:  |  Height:  |  Size: 629 B

2960
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -12,12 +12,19 @@ rust-version = "1.60"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies] [build-dependencies]
tauri-build = { version = "1.5.3", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.7.0", features = [ "protocol-all", "fs-all", "path-all", "window-all", "process-all", "notification-all", "dialog-all"] } tauri = { version = "2", features = ["protocol-asset"] }
image = "0.25.2"
webp = "0.3.0"
gif = "0.13.1"
tauri-plugin-notification = "2"
tauri-plugin-process = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
[features] [features]
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.

View file

@ -0,0 +1,77 @@
{
"identifier": "migrated",
"description": "permissions that were migrated from v1",
"local": true,
"windows": [
"main"
],
"permissions": [
"core:default",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-read-dir",
"fs:allow-copy-file",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-exists",
{
"identifier": "fs:scope",
"allow": [
"**",
"**/*",
"/**/*",
"$CONFIG/stort/",
"$CONFIG/stort/**",
"$HOME/.local/share/stort/*",
"$HOME/.local/share/stort/**"
]
},
"core:window:allow-create",
"core:window:allow-center",
"core:window:allow-request-user-attention",
"core:window:allow-set-resizable",
"core:window:allow-set-maximizable",
"core:window:allow-set-minimizable",
"core:window:allow-set-closable",
"core:window:allow-set-title",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-minimize",
"core:window:allow-unminimize",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-close",
"core:window:allow-set-decorations",
"core:window:allow-set-always-on-top",
"core:window:allow-set-content-protected",
"core:window:allow-set-size",
"core:window:allow-set-min-size",
"core:window:allow-set-max-size",
"core:window:allow-set-position",
"core:window:allow-set-fullscreen",
"core:window:allow-set-focus",
"core:window:allow-set-icon",
"core:window:allow-set-skip-taskbar",
"core:window:allow-set-cursor-grab",
"core:window:allow-set-cursor-visible",
"core:window:allow-set-cursor-icon",
"core:window:allow-set-cursor-position",
"core:window:allow-set-ignore-cursor-events",
"core:window:allow-start-dragging",
"core:webview:allow-print",
"dialog:allow-open",
"dialog:allow-save",
"dialog:allow-message",
"dialog:allow-ask",
"dialog:allow-confirm",
"notification:default",
"process:allow-restart",
"process:allow-exit",
"notification:default",
"process:default",
"dialog:default",
"fs:default"
]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists",{"identifier":"fs:scope","allow":["**","**/*","/**/*","$CONFIG/stort/","$CONFIG/stort/**","$HOME/.local/share/stort/*","$HOME/.local/share/stort/**"]},"core:window:allow-create","core:window:allow-center","core:window:allow-request-user-attention","core:window:allow-set-resizable","core:window:allow-set-maximizable","core:window:allow-set-minimizable","core:window:allow-set-closable","core:window:allow-set-title","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-minimize","core:window:allow-unminimize","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-decorations","core:window:allow-set-always-on-top","core:window:allow-set-content-protected","core:window:allow-set-size","core:window:allow-set-min-size","core:window:allow-set-max-size","core:window:allow-set-position","core:window:allow-set-fullscreen","core:window:allow-set-focus","core:window:allow-set-icon","core:window:allow-set-skip-taskbar","core:window:allow-set-cursor-grab","core:window:allow-set-cursor-visible","core:window:allow-set-cursor-icon","core:window:allow-set-cursor-position","core:window:allow-set-ignore-cursor-events","core:window:allow-start-dragging","core:webview:allow-print","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","notification:default","process:allow-restart","process:allow-exit","notification:default","process:default","dialog:default","fs:default"]}}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,96 @@
use app::fs::move_file;
#[derive(Debug, PartialEq)]
enum ImageType {
Jpeg,
Png,
Gif,
WebP,
AnimatedWebP,
Unsupported,
}
/// determines the image type of a file that is passed in using the filepath
fn determine_image_type(file_path: &std::path::Path) -> Result<ImageType, String> {
// open the file
let mut file =
std::fs::File::open(file_path).map_err(|e| format!("Failed to open file: {e}"))?;
// read the first few bytes to determine the format
let mut buffer = [0; 12];
std::io::Read::read_exact(&mut file, &mut buffer)
.map_err(|e| format!("Failed to read file: {e}"))?;
// with hopes and prayers, try to guess the format and pray that it is correct haha
match image::guess_format(&buffer) {
Ok(image::ImageFormat::Jpeg) => Ok(ImageType::Jpeg),
Ok(image::ImageFormat::Png) => Ok(ImageType::Png),
Ok(image::ImageFormat::Gif) => Ok(ImageType::Gif),
Ok(image::ImageFormat::WebP) => {
// additional check for webp
is_animated_webp(file_path).map(|animated| {
if animated {
ImageType::AnimatedWebP
} else {
ImageType::WebP
}
})
}
_ => Ok(ImageType::Unsupported),
}
}
/// check if the file is a valid webp image and if it is animated
/// returns `Ok(true)` if it is animated, `Ok(false)` if it is not
/// and errors out if the file cannot be read or is not a webp
fn is_animated_webp(file_path: &std::path::Path) -> Result<bool, String> {
// open the file and read its contents into a buffer
let mut buffer = Vec::new();
std::fs::File::open(file_path)
.and_then(|mut file| std::io::Read::read_to_end(&mut file, &mut buffer))
.map_err(|e| format!("Failed to read file: {}", e))?;
// use the webp crate to decode the image and check for animation
webp::AnimDecoder::new(&buffer)
.decode()
.map(|anim| anim.has_animation())
.map_err(|_| "File is not a valid WebP image".to_string())
}
fn get_file_extension(file_path: &std::path::Path) -> Result<String, String> {
file_path
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_lowercase())
.ok_or_else(|| "Failed to get file extension".to_string())
}
/// function to interface with the tauri api on the javascript side
fn process_wallpaper_image(file_path_string: String) -> Result<String, String> {
// convert the string to a path
let file_path = std::path::Path::new(&file_path_string);
// determine the image type
match determine_image_type(file_path) {
Ok(image_type) => {
// convert the image type to a string extension for printing
let extension = match image_type {
ImageType::Jpeg => "jpeg",
ImageType::Png => "png",
ImageType::Gif => "gif",
ImageType::WebP => "webp",
ImageType::AnimatedWebP => "webp",
ImageType::Unsupported => "",
};
Ok(extension.to_string())
}
Err(e) => Err(format!("Error determining image type: {e}")),
}
}
fn main() {
let target_path = std::path::Path::new("/home/vomitblood/Downloads/asdf.csv");
let destination_path = std::path::Path::new("/home/vomitblood/Downloads/asdf2.csv");
move_file(target_path, destination_path, true).expect("Failed to move file");
}

View file

@ -0,0 +1,2 @@
pub const APP_NAME: &str = "stort";
pub const APP_TITLE: &str = "Stort";

111
src-tauri/src/fs.rs Normal file
View file

@ -0,0 +1,111 @@
pub fn move_file(
target: &std::path::Path,
destination: &std::path::Path,
create_dirs: bool,
) -> std::io::Result<()> {
// check if the target file exists
if !target.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Source file not found: {:?}", target),
));
}
// determine the final destination path
let final_destination = if destination.is_dir() {
// if the destination is a directory, then append the target file name to the destination path
let mut dest_path = destination.to_path_buf();
if let Some(file_name) = target.file_name() {
dest_path.push(file_name);
}
dest_path
} else {
// if destination is already a file path, then use it directly
destination.to_path_buf()
};
// create the necessary directories if the flag is set
if create_dirs {
if let Some(parent) = final_destination.parent() {
std::fs::create_dir_all(parent)?;
}
}
// attempt to rename (move) the file
match std::fs::rename(target, &final_destination) {
Ok(_) => Ok(()),
Err(e) => {
eprintln!("Failed to rename file: {:?}. Trying fallback method...", e);
// if rename fails, try to copy the file instead
if let Err(copy_error) = std::fs::copy(target, &final_destination) {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to copy file: {:?}", copy_error),
));
}
if let Err(remove_error) = std::fs::remove_file(target) {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to remove original file: {:?}", remove_error),
));
}
Ok(())
}
}
}
pub fn copy_file(
target: &std::path::Path,
destination: &std::path::Path,
create_dirs: bool,
) -> std::io::Result<()> {
// check if the target file exists
if !target.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Source file not found: {:?}", target),
));
}
// determine the final destination path
let final_destination = if destination.is_dir() {
// if the destination is a directory, append the target file name
let mut dest_path = destination.to_path_buf();
if let Some(file_name) = target.file_name() {
dest_path.push(file_name);
}
dest_path
} else {
// if destination is a file path, use it directly
destination.to_path_buf()
};
// create the necessary directories if the flag is set
if create_dirs {
if let Some(parent) = final_destination.parent() {
std::fs::create_dir_all(parent)?;
}
}
// copy the file
// why need to use map???
std::fs::copy(target, &final_destination).map(|_| ())
}
pub fn delete_file(file_path: &std::path::Path) -> std::io::Result<()> {
// attempt to delete the file
if file_path.exists() {
std::fs::remove_file(file_path)?;
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("File not found: {:?}", file_path),
))
}
}
pub fn create_directory(directory_path: &std::path::Path) -> std::io::Result<()> {
// attempt to create the directory
std::fs::create_dir_all(directory_path)
}

View file

@ -0,0 +1,11 @@
pub fn initialize_directories() {
// getting the directories will also create them if they dont exist
// get the app data directory
crate::paths::get_app_data_dir().expect("Failed to get or create app data directory");
// get the app cache directory
crate::paths::get_app_data_dir().expect("Failed to get or create app data directory");
// get the app config directory
crate::paths::get_app_data_dir().expect("Failed to get or create app data directory");
}

6
src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,6 @@
pub mod constants;
pub mod fs;
pub mod initialize;
pub mod paths;
pub mod tauri;
pub mod wallpaper;

View file

@ -1,14 +1,7 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[tauri::command]
fn hello(content: &str) -> String {
format!("Hello {}, what is your name?!", content)
}
fn main() { fn main() {
tauri::Builder::default() app::initialize::initialize_directories();
.invoke_handler(tauri::generate_handler![hello]) app::tauri::run_tauri_app();
.run(tauri::generate_context!())
.expect("error while running tauri application");
} }

62
src-tauri/src/paths.rs Normal file
View file

@ -0,0 +1,62 @@
pub fn get_app_data_dir() -> Result<std::path::PathBuf, String> {
// attempt to get the app data directory
match tauri::api::path::data_dir() {
Some(data_dir) => {
let app_data_dir = data_dir.join(crate::constants::APP_NAME);
// ensure the directory exists
if !app_data_dir.exists() {
// attempt to create the directory
std::fs::create_dir_all(&app_data_dir).map_err(|e| {
format!(
"Failed to create app data directory {:?}: {}",
app_data_dir, e
)
})?;
}
Ok(app_data_dir)
}
None => Err("Failed to get app data directory".to_string()),
}
}
pub fn get_app_cache_dir() -> Result<std::path::PathBuf, String> {
// attempt to get the app cache directory
match tauri::api::path::cache_dir() {
Some(cache_dir) => {
let app_cache_dir = cache_dir.join(crate::constants::APP_NAME);
// ensure the directory exists
if !app_cache_dir.exists() {
// attempt to create the directory
std::fs::create_dir_all(&app_cache_dir).map_err(|e| {
format!(
"Failed to create app cache directory {:?}: {}",
app_cache_dir, e
)
})?;
}
Ok(app_cache_dir)
}
None => Err("Failed to get app cache directory".to_string()),
}
}
pub fn get_app_config_dir() -> Result<std::path::PathBuf, String> {
// attempt to get the app config directory
match tauri::api::path::config_dir() {
Some(config_dir) => {
let app_config_dir = config_dir.join(crate::constants::APP_NAME);
// ensure the directory exists
if !app_config_dir.exists() {
// attempt to create the directory
std::fs::create_dir_all(&app_config_dir).map_err(|e| {
format!(
"Failed to create app config directory {:?}: {}",
app_config_dir, e
)
})?;
}
Ok(app_config_dir)
}
None => Err("Failed to get app config directory".to_string()),
}
}

9
src-tauri/src/tauri.rs Normal file
View file

@ -0,0 +1,9 @@
pub fn run_tauri_app() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
crate::wallpaper::process_wallpaper_image,
crate::wallpaper::delete_old_wallpaper_images,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

123
src-tauri/src/wallpaper.rs Normal file
View file

@ -0,0 +1,123 @@
#[derive(Debug, PartialEq)]
enum ImageType {
Jpeg,
Png,
Gif,
WebP,
AnimatedWebP,
Unsupported,
}
/// function to interface with the tauri api on the javascript side
#[tauri::command]
pub fn process_wallpaper_image(file_path_string: String) -> Result<String, String> {
println!("{file_path_string}");
// convert the string to a path
let file_path = std::path::Path::new(&file_path_string);
// determine the image type and get the file extension
match determine_image_type(file_path) {
Ok(image_type) => match image_type {
ImageType::Jpeg => "jpeg".to_string(),
ImageType::Png => "png".to_string(),
ImageType::Gif => "gif".to_string(),
ImageType::WebP => "webp".to_string(),
ImageType::AnimatedWebP => "webp".to_string(),
ImageType::Unsupported => {
return Err("Unsupported image type (accepts webp, jpg, jpeg, png, gif)".to_string())
}
},
Err(e) => return Err(format!("Error determining image type: {e}")),
};
let app_data_dir = crate::paths::get_app_data_dir()
.map_err(|e| format!("Failed to get app data directory: {e}"))?;
// construct the destination path
let destination_path = app_data_dir
.join("wallpaper")
.join(file_path.file_name().unwrap());
// move the file to the destination
crate::fs::copy_file(file_path, &destination_path, true)
.map_err(|e| format!("Failed to move file: {e}"))?;
Ok(destination_path.to_string_lossy().to_string())
}
#[tauri::command]
pub fn delete_old_wallpaper_images() -> Result<(), String> {
// convert the string to a Path
let app_data_dir = crate::paths::get_app_data_dir()
.map_err(|e| format!("Failed to get app data directory: {e}"))?;
let directory_path = app_data_dir.join("wallpaper");
// check if the directory exists
if !directory_path.is_dir() {
return Err(format!("Path is not a directory: {:?}", directory_path));
}
// iterate over the files in the directory
for entry in
std::fs::read_dir(directory_path).map_err(|e| format!("Failed to read directory: {e}"))?
{
let entry = entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
let path = entry.path();
// delete the file
if path.is_file() {
std::fs::remove_file(&path)
.map_err(|e| format!("Failed to delete file {:?}: {e}", path))?;
}
}
Ok(())
}
/// determines the image type of a file that is passed in using the filepath
fn determine_image_type(file_path: &std::path::Path) -> Result<ImageType, String> {
// open the file
let mut file =
std::fs::File::open(file_path).map_err(|e| format!("Failed to open file: {e}"))?;
// read the first few bytes to determine the format
let mut buffer = [0; 12];
std::io::Read::read_exact(&mut file, &mut buffer)
.map_err(|e| format!("Failed to read file: {e}"))?;
// with hopes and prayers, try to guess the format and pray that it is correct haha
match image::guess_format(&buffer) {
Ok(image::ImageFormat::Jpeg) => Ok(ImageType::Jpeg),
Ok(image::ImageFormat::Png) => Ok(ImageType::Png),
Ok(image::ImageFormat::Gif) => Ok(ImageType::Gif),
Ok(image::ImageFormat::WebP) => {
// additional check for webp
is_animated_webp(file_path).map(|animated| {
if animated {
ImageType::AnimatedWebP
} else {
ImageType::WebP
}
})
}
_ => Ok(ImageType::Unsupported),
}
}
/// check if the file is a valid webp image and if it is animated
/// returns `Ok(true)` if it is animated, `Ok(false)` if it is not
/// and errors out if the file cannot be read or is not a webp
fn is_animated_webp(file_path: &std::path::Path) -> Result<bool, String> {
// open the file and read its contents into a buffer
let mut buffer = Vec::new();
std::fs::File::open(file_path)
.and_then(|mut file| std::io::Read::read_to_end(&mut file, &mut buffer))
.map_err(|e| format!("Failed to read file: {}", e))?;
// use the webp crate to decode the image and check for animation
webp::AnimDecoder::new(&buffer)
.decode()
.map(|anim| anim.has_animation())
.map_err(|_| "File is not a valid WebP image".to_string())
}

View file

@ -1,82 +1,42 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/schema.json", "$schema": "../node_modules/@tauri-apps/cli/schema.json",
"build": { "build": {
"beforeBuildCommand": "bun run build", "beforeBuildCommand": "npm run build",
"beforeDevCommand": "bun run dev", "beforeDevCommand": "npm run dev",
"devPath": "http://localhost:3000", "frontendDist": "../out",
"distDir": "../out" "devUrl": "http://localhost:3000"
}, },
"package": { "bundle": {
"productName": "stort", "active": true,
"version": "0.1.0" "category": "DeveloperTool",
}, "copyright": "",
"tauri": { "shortDescription": "Launcher for Steam Deck",
"allowlist": { "externalBin": [],
"dialog": { "icon": [
"all": true "icons/32x32.png",
}, "icons/128x128.png",
"fs": { "icons/128x128@2x.png",
"all": true, "icons/icon.icns",
"scope": [ "icons/icon.ico"
"**", ],
"**/*", "targets": [
"/**/*", "appimage",
"$CONFIG/stort/", "deb"
"$CONFIG/stort/**" ],
] "longDescription": "Launcher for Steam Deck",
}, "resources": [],
"notification": { "linux": {
"all": true
},
"path": {
"all": true
},
"process": {
"all": true
},
"protocol": {
"all": true,
"asset": true,
"assetScope": [
"**",
"**/*",
"/**/*"
]
},
"window": {
"all": true
}
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"deb": { "deb": {
"depends": [] "depends": []
}, }
"externalBin": [], }
"icon": [ },
"icons/32x32.png", "productName": "stort",
"icons/128x128.png", "mainBinaryName": "stort",
"icons/128x128@2x.png", "version": "0.1.0",
"icons/icon.icns", "identifier": "stort",
"icons/icon.ico" "plugins": {},
], "app": {
"identifier": "com.vomitblood.stort",
"longDescription": "Launcher for Steam Deck",
"resources": [],
"shortDescription": "Launcher for Steam Deck",
"targets": [
"appimage",
"deb"
]
},
"security": {
"csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost"
},
"updater": {
"active": false
},
"windows": [ "windows": [
{ {
"decorations": false, "decorations": false,
@ -86,6 +46,16 @@
"title": "Stort", "title": "Stort",
"width": 800 "width": 800
} }
] ],
"security": {
"assetProtocol": {
"scope": [
"$APPDATA/*",
"$APPDATA/**"
],
"enable": true
},
"csp": null
}
} }
} }

View file

@ -1,57 +0,0 @@
import { Settings } from "@mui/icons-material";
import { Box, Stack } from "@mui/material";
import { useRouter } from "next/router";
import { WindowButtons } from "./HeaderBar/WindowButtons";
export const FooterBar = () => {
return (
<Box
className="titlebar"
data-tauri-drag-region="true"
sx={{
alignItems: "center",
backdropFilter: "blur(10px)",
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
flexDirection: "row",
height: "48px",
justifyContent: "space-between",
p: 1,
}}
>
<Box
className="titlebar"
data-tauri-drag-region="true"
sx={{
alignItems: "center",
display: "flex",
flexDirection: "row",
}}
>
hello this is the left side
</Box>
<Box
className="titlebar"
data-tauri-drag-region="true"
sx={{
alignItems: "center",
display: "flex",
flexDirection: "row",
}}
>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "center",
display: "flex",
flexDirection: "row",
}}
>
<Settings />
<WindowButtons />
</Stack>
</Box>
</Box>
);
};

View file

@ -0,0 +1,84 @@
import { Box, Stack } from "@mui/material";
import { useSettings } from "../../contexts/SettingsContext";
import { hexToRgba } from "../../lib/utils/color";
import { Settings } from "../HeaderBar/Settings/Settings";
import { WindowButtons } from "../HeaderBar/WindowButtons";
export const FooterBar = () => {
// contexts
const { settings } = useSettings();
const { r, g, b, a } = hexToRgba(settings.colors.footer_color);
return (
<Box
sx={{
alignItems: "center",
display: "flex",
flexDirection: "row",
height: "66px",
// zIndex: 1000000,
}}
>
<Box
className="titlebar"
data-tauri-drag-region="true"
sx={{
alignItems: "center",
backdropFilter: `blur(${settings.style.blur_radius}px)`,
backgroundColor: `rgba(${r}, ${g}, ${b}, 0.5)`,
borderRadius: settings.style.radius + "px",
display: "flex",
flexDirection: "row",
flexGrow: 1,
m: 1,
p: 1,
width: "100%",
}}
>
<Box
className="titlebar"
data-tauri-drag-region="true"
sx={{
alignItems: "center",
display: "flex",
flexDirection: "row",
}}
>
hello this is the left side
</Box>
<Box
sx={{
alignItems: "center",
display: "flex",
flexDirection: "row",
flexGrow: 1,
justifyContent: "center",
}}
/>
<Box
className="titlebar"
data-tauri-drag-region="true"
sx={{
alignItems: "center",
display: "flex",
flexDirection: "row",
}}
>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "center",
display: "flex",
flexDirection: "row",
}}
>
<Settings />
<WindowButtons />
</Stack>
</Box>
</Box>
</Box>
);
};

View file

@ -1,7 +1,8 @@
import { Close, UnfoldLess, UnfoldMore } from "@mui/icons-material"; import { Close, UnfoldLess, UnfoldMore } from "@mui/icons-material";
import { Box, IconButton, Modal, Tooltip, Typography } from "@mui/material"; import { Box, IconButton, Modal, Tooltip, Typography, useTheme } from "@mui/material";
import { FC, ReactNode } from "react"; import { FC, ReactNode } from "react";
import { useSettings } from "../../contexts/SettingsContext"; import { useSettings } from "../../contexts/SettingsContext";
import { hexToRgba } from "../../lib/utils/color";
interface FloatingDialog { interface FloatingDialog {
actionButtons?: ReactNode; actionButtons?: ReactNode;
@ -28,7 +29,11 @@ export const FloatingDialog: FC<FloatingDialog> = ({
title, title,
sx, sx,
}) => { }) => {
// contexts
const { settings } = useSettings(); const { settings } = useSettings();
const theme = useTheme();
const { r, g, b, a } = hexToRgba(theme.palette.background.paper);
return ( return (
<> <>
@ -40,7 +45,8 @@ export const FloatingDialog: FC<FloatingDialog> = ({
> >
<Box <Box
sx={{ sx={{
bgcolor: "background.paper", backdropFilter: `blur(${settings.style.blur_radius}px)`,
backgroundColor: `rgba(${r}, ${g}, ${b}, ${settings.style.opacity})`,
borderRadius: maximisedState ? "0px" : settings.style.radius + "px", borderRadius: maximisedState ? "0px" : settings.style.radius + "px",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",

View file

@ -1,33 +0,0 @@
import { createDir, exists } from "@tauri-apps/api/fs";
import { useEffect } from "react";
import { useSettings } from "../../contexts/SettingsContext";
import Paths from "../../lib/path";
export const Initialization = () => {
const { settings } = useSettings();
useEffect(() => {
const initializePaths = async () => {
try {
await Paths.initialize();
} catch (error) {
console.error(`Failed to initialize paths: ${error}`);
}
};
const createDirectories = async () => {
const configDirectoryExists = await exists(Paths.getPath("configDirectory"));
if (!configDirectoryExists) await createDir(Paths.getPath("configDirectory"));
};
const fullscreen = async () => {
const { appWindow } = await import("@tauri-apps/api/window");
await appWindow.setFullscreen(true);
};
initializePaths().then(() => createDirectories());
if (settings.window.start_fullscreen) fullscreen();
}, []);
return null;
};

View file

@ -1,29 +1,34 @@
import { Box, Button } from "@mui/material"; import { Box, Button, useTheme } from "@mui/material";
import { convertFileSrc } from "@tauri-apps/api/tauri"; import { convertFileSrc } from "@tauri-apps/api/core";
import { useState } from "react"; import { useEffect, useState } from "react";
import { FooterBar } from "../FooterBar";
import { HeaderBar } from "../HeaderBar/HeaderBar";
import { useSettings } from "../../contexts/SettingsContext"; import { useSettings } from "../../contexts/SettingsContext";
import { FooterBar } from "../FooterBar/FooterBar";
import { HeaderBar } from "../HeaderBar/HeaderBar";
export const Layout = () => { export const Layout = () => {
// contexts
const { settings } = useSettings(); const { settings } = useSettings();
const theme = useTheme();
const [imageUrl, setImageUrl] = useState<string | null>(null); const [imageUrl, setImageUrl] = useState<string | null>(null);
const setBackground = async () => { const setBackground = async (filePath: string) => {
const assetUrl = convertFileSrc("/home/vomitblood/Downloads/toothless-dance.gif"); const assetUrl = convertFileSrc(filePath);
setImageUrl(assetUrl); setImageUrl(assetUrl);
}; };
useEffect(() => {
setBackground(settings.background.background_image_path);
}, [settings.background.background_image_path]);
return ( return (
<Box <Box
sx={{ sx={{
// Use the URL function for background images backgroundColor: theme.palette.background.default,
backgroundColor: settings.background.background_color, backgroundImage: settings.background.background_image_path ? `url(${imageUrl})` : "",
backgroundImage: `url(${imageUrl})`, backgroundSize: "cover",
backgroundSize: "cover", // Cover the entire area backgroundPosition: "center",
backgroundPosition: "center", // Center the image backgroundRepeat: "no-repeat",
backgroundRepeat: "no-repeat", // Do not repeat the image
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
height: "100vh", height: "100vh",
@ -35,18 +40,9 @@ export const Layout = () => {
display: "flex", display: "flex",
flexGrow: 1, flexGrow: 1,
overflow: "auto", overflow: "auto",
p: 1,
}} }}
> ></Box>
<Box>
<Button
onClick={() => {
setBackground();
}}
>
set background
</Button>
</Box>
</Box>
<FooterBar /> <FooterBar />
</Box> </Box>
); );

View file

@ -1,21 +1,28 @@
import { BugReportOutlined, SettingsOutlined } from "@mui/icons-material"; import {
import { LoadingButton, TabContext, TabList, TabPanel } from "@mui/lab"; BugReportOutlined,
FormatPaintOutlined,
PaletteOutlined,
SettingsOutlined,
WallpaperOutlined,
WebAssetOutlined,
} from "@mui/icons-material";
import { TabContext, TabList, TabPanel } from "@mui/lab";
import { Box, Button, IconButton, Tab, Tooltip, useTheme } from "@mui/material"; import { Box, Button, IconButton, Tab, Tooltip, useTheme } from "@mui/material";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSettings } from "../../../contexts/SettingsContext"; import { useSettings } from "../../../contexts/SettingsContext";
import { stagedSettingsAtom } from "../../../lib/store/jotai/settings"; import { stagedSettingsAtom } from "../../../lib/store/jotai/settings";
import { FloatingDialog } from "../../Generic/FloatingDialog"; import { FloatingDialog } from "../../Generic/FloatingDialog";
import { SettingsTabStyle } from "./SettingsTabs/SettingsTabStyle"; import { Background } from "./SettingsTabs/Background";
import { SettingsTabWindow } from "./SettingsTabs/SettingsTabWindow"; import { Debug } from "./SettingsTabs/Debug";
import { SettingsTabBackground } from "./SettingsTabs/SettingsTabBackground"; import { Style } from "./SettingsTabs/Style";
import { Window } from "./SettingsTabs/Window";
import { Colors } from "./SettingsTabs/Colors";
export const Settings = () => { export const Settings = () => {
// contexts // contexts
const theme = useTheme(); const theme = useTheme();
const { settings, updateSettings } = useSettings(); const { fetchSettings, settings, updateSettings } = useSettings();
const router = useRouter();
// atoms // atoms
const [stagedSettings, setStagedSettings] = useAtom(stagedSettingsAtom); const [stagedSettings, setStagedSettings] = useAtom(stagedSettingsAtom);
@ -24,8 +31,6 @@ export const Settings = () => {
const [settingsOpenState, setSettingsOpenState] = useState<boolean>(false); const [settingsOpenState, setSettingsOpenState] = useState<boolean>(false);
const [settingsMaximisedState, setSettingsMaximisedState] = useState<boolean>(false); const [settingsMaximisedState, setSettingsMaximisedState] = useState<boolean>(false);
const [subTabValue, setSubTabValue] = useState("style"); const [subTabValue, setSubTabValue] = useState("style");
const [applyLoading, setApplyLoading] = useState<boolean>(false);
const [saveLoading, setSaveLoading] = useState<boolean>(false);
const toggleSettings = () => { const toggleSettings = () => {
setSettingsOpenState((prevState) => !prevState); setSettingsOpenState((prevState) => !prevState);
@ -40,43 +45,31 @@ export const Settings = () => {
setSubTabValue(newTabValue); setSubTabValue(newTabValue);
}; };
// set staged settings back to current settings on cancel
const cancelClickEvent = () => {
setStagedSettings(settings);
setSettingsOpenState(false);
};
const applyClickEvent = () => { const applyClickEvent = () => {
setApplyLoading(true);
updateSettings(stagedSettings); updateSettings(stagedSettings);
setApplyLoading(false);
}; };
const saveClickEvent = () => { const saveClickEvent = () => {
setSaveLoading(true); applyClickEvent();
updateSettings(stagedSettings);
setSaveLoading(false);
closeSettings(); closeSettings();
}; };
// set staged settings back to current settings on close
useEffect(() => { useEffect(() => {
setStagedSettings(settings); if (settingsOpenState) fetchSettings();
}, [setStagedSettings, settings]); if (!settingsOpenState) setStagedSettings(settings);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsOpenState]);
return ( return (
<FloatingDialog <FloatingDialog
actionButtons={
<>
<IconButton
onClick={() => {
router.push("/testing");
}}
sx={{
mr: 1,
}}
>
<BugReportOutlined />
</IconButton>
</>
}
body={ body={
<Box <Box
sx={{ sx={{
@ -98,26 +91,41 @@ export const Settings = () => {
scrollButtons={true} scrollButtons={true}
sx={{ sx={{
borderBottom: "1px solid " + theme.palette.divider, borderBottom: "1px solid " + theme.palette.divider,
height: "84px",
}} }}
variant="scrollable" variant="scrollable"
> >
<Tab <Tab
icon={<FormatPaintOutlined />}
label="Style" label="Style"
value="style" value="style"
/> />
<Tab <Tab
icon={<PaletteOutlined />}
label="Colors"
value="colors"
/>
<Tab
icon={<WallpaperOutlined />}
label="Background" label="Background"
value="background" value="background"
/> />
<Tab <Tab
icon={<WebAssetOutlined />}
label="Window" label="Window"
value="window" value="window"
/> />
<Tab
icon={<BugReportOutlined />}
label="Debug"
value="debug"
/>
</TabList> </TabList>
<Box <Box
overflow="auto"
sx={{ sx={{
height: "100%", height: "100%",
overflowY: "auto",
m: 0,
width: "100%", width: "100%",
}} }}
> >
@ -125,19 +133,31 @@ export const Settings = () => {
sx={{ p: 2 }} sx={{ p: 2 }}
value="style" value="style"
> >
<SettingsTabStyle /> <Style />
</TabPanel>
<TabPanel
sx={{ p: 2 }}
value="colors"
>
<Colors />
</TabPanel> </TabPanel>
<TabPanel <TabPanel
sx={{ p: 2 }} sx={{ p: 2 }}
value="background" value="background"
> >
<SettingsTabBackground /> <Background />
</TabPanel> </TabPanel>
<TabPanel <TabPanel
sx={{ p: 2 }} sx={{ p: 2 }}
value="window" value="window"
> >
<SettingsTabWindow /> <Window />
</TabPanel>
<TabPanel
sx={{ p: 2 }}
value="debug"
>
<Debug />
</TabPanel> </TabPanel>
</Box> </Box>
</TabContext> </TabContext>
@ -152,7 +172,7 @@ export const Settings = () => {
}} }}
> >
<Button <Button
onClick={() => setSettingsOpenState(false)} onClick={cancelClickEvent}
size="small" size="small"
sx={{ sx={{
mr: 1, mr: 1,
@ -162,8 +182,7 @@ export const Settings = () => {
Cancel Cancel
</Button> </Button>
<LoadingButton <Button
loading={applyLoading}
onClick={applyClickEvent} onClick={applyClickEvent}
size="small" size="small"
sx={{ sx={{
@ -172,15 +191,14 @@ export const Settings = () => {
variant="outlined" variant="outlined"
> >
Apply Apply
</LoadingButton> </Button>
<LoadingButton <Button
loading={saveLoading}
onClick={saveClickEvent} onClick={saveClickEvent}
size="small" size="small"
variant="contained" variant="contained"
> >
Save Save
</LoadingButton> </Button>
</Box> </Box>
} }
close={closeSettings} close={closeSettings}

View file

@ -0,0 +1,222 @@
import { DeleteOutline, FileOpenOutlined } from "@mui/icons-material";
import { Box, Button, CircularProgress, LinearProgress, Stack, TextField, Typography } from "@mui/material";
import { open } from "@tauri-apps/plugin-dialog";
import { readBinaryFile } from "@tauri-apps/plugin-fs";
import { invoke } from "@tauri-apps/api/core";
import { useAtom } from "jotai";
import Image from "next/image";
import { FC, useEffect, useState } from "react";
import { useSettings } from "../../../../contexts/SettingsContext";
import { defaultSettings } from "../../../../lib/settings";
import { stagedSettingsAtom } from "../../../../lib/store/jotai/settings";
import { CategoryTitle } from "../CategoryTitle";
import { SettingsItem } from "../SettingsItem";
interface BackgroundProps {
sx?: any;
}
export const Background: FC<BackgroundProps> = ({ sx }) => {
// contexts
const { settings } = useSettings();
// atoms
const [stagedSettings, setStagedSettings] = useAtom(stagedSettingsAtom);
// states
const [oldWallpaperPath, setOldWallpaperPath] = useState<string | null>(null);
const [targetWallpaperPath, setTargetWallpaperPath] = useState<string | null>(null);
const [imageBlob, setImageBlob] = useState<string | null>(null);
const handleSettingsBackgroundValueChange = (
settingKey: string,
settingValue: boolean | number | string | number[],
) => {
const newSettings = {
...stagedSettings,
background: {
...stagedSettings.background,
[settingKey]: settingValue,
},
};
setStagedSettings(newSettings);
return newSettings;
};
const setImageSrc = async (filePath: string) => {
const imageBlobTemp = await readBinaryFile(filePath);
if (imageBlobTemp) setImageBlob(URL.createObjectURL(new Blob([imageBlobTemp])));
};
const selectImage = async () => {
const { appLocalDataDir, basename } = await import("@tauri-apps/api/path");
// clear the states first
setTargetWallpaperPath(null);
setImageBlob(null);
let selectedFilePath = await open({
multiple: false,
filters: [
{
name: "Images",
extensions: ["jpg", "png", "jpeg", "webp", "gif"],
},
],
});
// if the user somehow manages to select multiple files, take the first file
if (Array.isArray(selectedFilePath)) {
selectedFilePath = selectedFilePath[0];
}
if (selectedFilePath) {
setTargetWallpaperPath(selectedFilePath);
// construct the destination file path
const appLocalDataDirPath = await appLocalDataDir();
const filename = await basename(selectedFilePath);
const destinationFilePath = appLocalDataDirPath + "wallpaper/" + filename;
handleSettingsBackgroundValueChange("background_image_path", destinationFilePath);
}
};
const clearImage = async () => {
handleSettingsBackgroundValueChange("background_image_path", "");
};
// if settings.background.background_image_path changes, update the image
useEffect(() => {
const applyWallpaper = async () => {
// apply the new wallpaper image
try {
// if there is already a wallpaper file, delete it
if (settings.background.background_image_path && oldWallpaperPath) {
try {
await invoke("delete_old_wallpaper_images");
} catch (error) {
console.error("Failed to delete old wallpaper image", error);
}
}
if (targetWallpaperPath)
await invoke("process_wallpaper_image", {
filePathString: targetWallpaperPath,
});
} catch (error) {
console.error(error);
}
};
applyWallpaper();
setOldWallpaperPath(settings.background.background_image_path);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.background.background_image_path]);
// update the preview image when stagedSettings.background.background_image_path changes
useEffect(() => {
if (targetWallpaperPath) {
setImageSrc(targetWallpaperPath);
} else {
setImageBlob(null);
}
}, [targetWallpaperPath]);
useEffect(() => {});
return (
<Box sx={{ sx }}>
<CategoryTitle title="Wallpaper" />
<Box
sx={{
position: "relative",
// dynamic width based on the parent container
width: "100%",
// 3:2 aspect ratio (2/3 = 66.67%) qwuik mafs
paddingBottom: "66.67%",
// Hide overflow to maintain aspect ratio
overflow: "hidden",
borderRadius: "8px", // Optional: rounded corners
}}
>
{imageBlob ? (
<Image
alt="Image not found"
// fill the box r/catsareliquid
layout="fill"
objectFit="cover"
src={imageBlob}
/>
) : (
<Box
sx={{
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.1)",
display: "flex",
flexDirection: "column",
height: "100%",
justifyContent: "center",
position: "absolute",
width: "100%",
}}
>
{targetWallpaperPath ? (
<CircularProgress color="primary" />
) : (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt="Image not found"
src="/images/cry.webp"
style={{
height: "50%",
}}
/>
<Typography
color="text.disabled"
variant="h6"
>
No image selected
</Typography>
</>
)}
</Box>
)}
</Box>
<Box
sx={{
mb: 2,
mt: 1,
}}
>
<Stack
direction="row"
spacing={1}
>
<Button
color="primary"
onClick={() => {
selectImage();
}}
startIcon={<FileOpenOutlined />}
size="small"
variant="outlined"
>
Select
</Button>
<Button
color="warning"
onClick={clearImage}
startIcon={<DeleteOutline />}
size="small"
variant="outlined"
>
Clear
</Button>
</Stack>
</Box>
</Box>
);
};

View file

@ -0,0 +1,112 @@
import { Box, TextField } from "@mui/material";
import { useAtom } from "jotai";
import { FC } from "react";
import { defaultSettings } from "../../../../lib/settings";
import { stagedSettingsAtom } from "../../../../lib/store/jotai/settings";
import { CategoryTitle } from "../CategoryTitle";
import { SettingsItem } from "../SettingsItem";
interface ColorsProps {
sx?: any;
}
export const Colors: FC<ColorsProps> = ({ sx }) => {
// atoms
const [stagedSettings, setStagedSettings] = useAtom(stagedSettingsAtom);
// states
const handleSettingsColorsValueChange = (settingKey: string, settingValue: boolean | number | string | number[]) => {
const newSettings = {
...stagedSettings,
colors: {
...stagedSettings.colors,
[settingKey]: settingValue,
},
};
setStagedSettings(newSettings);
return newSettings;
};
return (
<Box sx={{ sx }}>
<CategoryTitle title="Colors" />
<SettingsItem
defaultText={defaultSettings.colors.accent_color}
description="Accent color"
input={
<TextField
name="accent_color"
onChange={(e) => {
handleSettingsColorsValueChange(e.target.name, e.target.value);
}}
sx={{
width: "100%",
}}
size="small"
type="color"
value={stagedSettings.colors.accent_color}
variant="standard"
/>
}
/>
<SettingsItem
defaultText={defaultSettings.colors.background_color}
description="Background color"
input={
<TextField
name="background_color"
onChange={(e) => {
handleSettingsColorsValueChange(e.target.name, e.target.value);
}}
sx={{
width: "100%",
}}
size="small"
type="color"
value={stagedSettings.colors.background_color}
variant="standard"
/>
}
/>
<SettingsItem
defaultText={defaultSettings.colors.background_color_popup}
description="Popup background color"
input={
<TextField
name="background_color_popup"
onChange={(e) => {
handleSettingsColorsValueChange(e.target.name, e.target.value);
}}
sx={{
width: "100%",
}}
size="small"
type="color"
value={stagedSettings.colors.background_color_popup}
variant="standard"
/>
}
/>
<SettingsItem
defaultText={defaultSettings.colors.footer_color}
description="Footer color"
input={
<TextField
name="footer_color"
onChange={(e) => {
handleSettingsColorsValueChange(e.target.name, e.target.value);
}}
sx={{
width: "100%",
}}
size="small"
type="color"
value={stagedSettings.colors.footer_color}
variant="standard"
/>
}
/>
</Box>
);
};

View file

@ -0,0 +1,52 @@
import { Box, Typography } from "@mui/material";
import { FC } from "react";
import { useSettings } from "../../../../contexts/SettingsContext";
import { CategoryTitle } from "../CategoryTitle";
import { stagedSettingsAtom } from "../../../../lib/store/jotai/settings";
import { useAtom } from "jotai";
import { defaultSettings } from "../../../../lib/settings";
interface DebugProps {
sx?: any;
}
export const Debug: FC<DebugProps> = ({ sx }) => {
// contexts
const { settings, resetSettings } = useSettings();
// atoms
const [stagedSettings, setStagedSettings] = useAtom(stagedSettingsAtom);
return (
<Box sx={{ sx }}>
<CategoryTitle title="Debug Panel" />
<Typography
color="error"
variant="h6"
>
Here do be dragons
</Typography>
<button
onClick={() => {
setStagedSettings(defaultSettings);
}}
>
reset settings
</button>
<button
onClick={() => {
console.log(settings);
}}
>
log settings
</button>
<button
onClick={() => {
console.log(settings.background.background_image_path);
}}
>
testing
</button>
</Box>
);
};

View file

@ -1,114 +0,0 @@
import { DeleteOutline, FileOpenOutlined } from "@mui/icons-material";
import { Box, Button, Stack, TextField } from "@mui/material";
import { open } from "@tauri-apps/api/dialog";
import { useAtom } from "jotai";
import Image from "next/image";
import { FC } from "react";
import { stagedSettingsAtom } from "../../../../lib/store/jotai/settings";
import { CategoryTitle } from "../CategoryTitle";
import { SettingsItem } from "../SettingsItem";
interface SettingsTabBackgroundProps {
sx?: any;
}
export const SettingsTabBackground: FC<SettingsTabBackgroundProps> = ({ sx }) => {
// atoms
const [stagedSettings, setStagedSettings] = useAtom(stagedSettingsAtom);
const handleSettingsBackgroundValueChange = (
settingKey: string,
settingValue: boolean | number | string | number[],
) => {
const newSettings = {
...stagedSettings,
background: {
...stagedSettings.background,
[settingKey]: settingValue,
},
};
setStagedSettings(newSettings);
};
const selectImage = async () => {
const selected = await open({
multiple: false,
filters: [
{
name: "Images",
extensions: ["jpg", "png", "jpeg", "webp", "gif"],
},
],
});
if (selected) {
console.log(selected);
}
};
// TODO: implement
const clearImage = async () => {};
return (
<Box sx={{ sx }}>
<CategoryTitle title="Wallpaper" />
<Box>
<Image
src="/wallpaper.jpg"
alt="Image not found"
width={200}
height={200}
/>
</Box>
<Box
sx={{
mb: 2,
}}
>
<Stack
direction="row"
spacing={1}
>
<Button
color="primary"
onClick={selectImage}
startIcon={<FileOpenOutlined />}
size="small"
variant="outlined"
>
Select
</Button>
<Button
color="warning"
onClick={clearImage}
startIcon={<DeleteOutline />}
size="small"
variant="outlined"
>
Clear
</Button>
</Stack>
</Box>
<CategoryTitle title="Colors" />
<SettingsItem
defaultText="#202124"
description="Background color"
input={
<TextField
name="background_color"
onChange={(e) => {
handleSettingsBackgroundValueChange(e.target.name, e.target.value);
}}
sx={{
width: "100%",
}}
size="small"
type="color"
value={stagedSettings.background.background_color}
variant="standard"
/>
}
/>
</Box>
);
};

View file

@ -1,17 +1,18 @@
import { Box, InputAdornment, MenuItem, Slider, Switch, TextField, Typography } from "@mui/material"; import { Box, InputAdornment, MenuItem, Slider, Switch, TextField } from "@mui/material";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { FC } from "react"; import { FC } from "react";
import { defaultSettings } from "../../../../lib/settings";
import { stagedSettingsAtom } from "../../../../lib/store/jotai/settings"; import { stagedSettingsAtom } from "../../../../lib/store/jotai/settings";
import { BetaChip } from "../BetaChip"; import { BetaChip } from "../BetaChip";
import { CategoryTitle } from "../CategoryTitle"; import { CategoryTitle } from "../CategoryTitle";
import { DevChip } from "../DevChip"; import { DevChip } from "../DevChip";
import { SettingsItem } from "../SettingsItem"; import { SettingsItem } from "../SettingsItem";
interface SettingsTabStyleProps { interface StyleProps {
sx?: any; sx?: any;
} }
export const SettingsTabStyle: FC<SettingsTabStyleProps> = ({ sx }) => { export const Style: FC<StyleProps> = ({ sx }) => {
// atoms // atoms
const [stagedSettings, setStagedSettings] = useAtom(stagedSettingsAtom); const [stagedSettings, setStagedSettings] = useAtom(stagedSettingsAtom);
@ -24,13 +25,15 @@ export const SettingsTabStyle: FC<SettingsTabStyleProps> = ({ sx }) => {
}, },
}; };
setStagedSettings(newSettings); setStagedSettings(newSettings);
return newSettings;
}; };
return ( return (
<Box sx={{ sx }}> <Box sx={{ sx }}>
<CategoryTitle title="Basic styles" /> <CategoryTitle title="Basic styles" />
<SettingsItem <SettingsItem
defaultText="On" defaultText={defaultSettings.style.dark_mode ? "On" : "Off"}
description={ description={
<> <>
<BetaChip /> <BetaChip />
@ -48,26 +51,7 @@ export const SettingsTabStyle: FC<SettingsTabStyleProps> = ({ sx }) => {
} }
/> />
<SettingsItem <SettingsItem
defaultText="#8ab4f8" defaultText={defaultSettings.style.font_family}
description="Accent color"
input={
<TextField
name="accent_color"
onChange={(e) => {
handleSettingsStyleValueChange(e.target.name, e.target.value);
}}
sx={{
width: "100%",
}}
size="small"
type="color"
value={stagedSettings.style.accent_color}
variant="standard"
/>
}
/>
<SettingsItem
defaultText="Monospace"
description="Font family" description="Font family"
inputLong={ inputLong={
<TextField <TextField
@ -92,7 +76,7 @@ export const SettingsTabStyle: FC<SettingsTabStyleProps> = ({ sx }) => {
} }
/> />
<SettingsItem <SettingsItem
defaultText="100%" defaultText={defaultSettings.style.font_scaling + "%"}
description="Font scaling" description="Font scaling"
inputBottom={ inputBottom={
<Slider <Slider
@ -132,20 +116,20 @@ export const SettingsTabStyle: FC<SettingsTabStyleProps> = ({ sx }) => {
/> />
<CategoryTitle title="Advanced settings" /> <CategoryTitle title="Advanced settings" />
<SettingsItem <SettingsItem
defaultText="200ms" defaultText={defaultSettings.style.blur_radius + "px"}
description={ description={
<> <>
<DevChip /> <DevChip />
Transition duration Blur radius
</> </>
} }
input={ input={
<TextField <TextField
InputProps={{ InputProps={{
endAdornment: <InputAdornment position="end">ms</InputAdornment>, endAdornment: <InputAdornment position="end">px</InputAdornment>,
inputProps: { min: 0, max: 100, step: 1 }, inputProps: { min: 0, max: 100, step: 1 },
}} }}
name="transition_duration" name="blur_radius"
onChange={(e) => { onChange={(e) => {
handleSettingsStyleValueChange(e.target.name, parseFloat(e.target.value)); handleSettingsStyleValueChange(e.target.name, parseFloat(e.target.value));
}} }}
@ -154,13 +138,13 @@ export const SettingsTabStyle: FC<SettingsTabStyleProps> = ({ sx }) => {
}} }}
size="small" size="small"
type="number" type="number"
value={stagedSettings.style.transition_duration} value={stagedSettings.style.blur_radius}
variant="standard" variant="standard"
/> />
} }
/> />
<SettingsItem <SettingsItem
defaultText="8px" defaultText={defaultSettings.style.radius + "px"}
description={ description={
<> <>
<DevChip /> <DevChip />
@ -188,7 +172,62 @@ export const SettingsTabStyle: FC<SettingsTabStyleProps> = ({ sx }) => {
} }
/> />
<SettingsItem <SettingsItem
defaultText="60%" defaultText={defaultSettings.style.opacity.toString()}
description={
<>
<DevChip />
Opacity
</>
}
input={
<TextField
InputProps={{
inputProps: { min: 0, max: 1, step: 0.01 },
}}
name="opacity"
onChange={(e) => {
handleSettingsStyleValueChange(e.target.name, parseFloat(e.target.value));
}}
sx={{
width: "100%",
}}
size="small"
type="number"
value={stagedSettings.style.opacity}
variant="standard"
/>
}
/>
<SettingsItem
defaultText={defaultSettings.style.transition_duration.toString()}
description={
<>
<DevChip />
Transition duration
</>
}
input={
<TextField
InputProps={{
endAdornment: <InputAdornment position="end">ms</InputAdornment>,
inputProps: { min: 0, max: 100, step: 1 },
}}
name="transition_duration"
onChange={(e) => {
handleSettingsStyleValueChange(e.target.name, parseFloat(e.target.value));
}}
sx={{
width: "100%",
}}
size="small"
type="number"
value={stagedSettings.style.transition_duration}
variant="standard"
/>
}
/>
<SettingsItem
defaultText={defaultSettings.style.window_height + "%"}
description={ description={
<> <>
<DevChip /> <DevChip />
@ -216,7 +255,7 @@ export const SettingsTabStyle: FC<SettingsTabStyleProps> = ({ sx }) => {
} }
/> />
<SettingsItem <SettingsItem
defaultText="400px" defaultText={defaultSettings.style.window_width + "px"}
description={ description={
<> <>
<DevChip /> <DevChip />

View file

@ -4,12 +4,13 @@ import { FC } from "react";
import { stagedSettingsAtom } from "../../../../lib/store/jotai/settings"; import { stagedSettingsAtom } from "../../../../lib/store/jotai/settings";
import { CategoryTitle } from "../CategoryTitle"; import { CategoryTitle } from "../CategoryTitle";
import { SettingsItem } from "../SettingsItem"; import { SettingsItem } from "../SettingsItem";
import { defaultSettings } from "../../../../lib/settings";
interface SettingsTabWindowProps { interface WindowProps {
sx?: any; sx?: any;
} }
export const SettingsTabWindow: FC<SettingsTabWindowProps> = ({ sx }) => { export const Window: FC<WindowProps> = ({ sx }) => {
// atoms // atoms
const [stagedSettings, setStagedSettings] = useAtom(stagedSettingsAtom); const [stagedSettings, setStagedSettings] = useAtom(stagedSettingsAtom);
@ -22,13 +23,15 @@ export const SettingsTabWindow: FC<SettingsTabWindowProps> = ({ sx }) => {
}, },
}; };
setStagedSettings(newSettings); setStagedSettings(newSettings);
return newSettings;
}; };
return ( return (
<Box sx={{ sx }}> <Box sx={{ sx }}>
<CategoryTitle title="Fullscreen" /> <CategoryTitle title="Fullscreen" />
<SettingsItem <SettingsItem
defaultText="On" defaultText={defaultSettings.window.start_fullscreen ? "On" : "Off"}
description="Fullscreen on startup" description="Fullscreen on startup"
input={ input={
<Switch <Switch
@ -42,7 +45,20 @@ export const SettingsTabWindow: FC<SettingsTabWindowProps> = ({ sx }) => {
/> />
<CategoryTitle title="Titlebar Buttons" /> <CategoryTitle title="Titlebar Buttons" />
<SettingsItem <SettingsItem
defaultText="Off" defaultText={defaultSettings.window.fullscreen_button ? "On" : "Off"}
description="Fullscreen button"
input={
<Switch
checked={stagedSettings.window.fullscreen_button}
name="fullscreen_button"
onChange={(e) => {
handleSettingsWindowValueChange(e.target.name, e.target.checked);
}}
/>
}
/>
<SettingsItem
defaultText={defaultSettings.window.minimize_button ? "On" : "Off"}
description="Minimize button" description="Minimize button"
input={ input={
<Switch <Switch
@ -55,7 +71,7 @@ export const SettingsTabWindow: FC<SettingsTabWindowProps> = ({ sx }) => {
} }
/> />
<SettingsItem <SettingsItem
defaultText="Off" defaultText={defaultSettings.window.maximize_button ? "On" : "Off"}
description="Maximize button" description="Maximize button"
input={ input={
<Switch <Switch

View file

@ -1,9 +1,8 @@
import { Close, CloseFullscreen, Minimize } from "@mui/icons-material"; import { Close, CloseFullscreen, Fullscreen, FullscreenExit, Minimize, WebAssetOutlined } from "@mui/icons-material";
import { Box, Button, ButtonGroup, IconButton, Stack, useTheme } from "@mui/material"; import { Box, IconButton, Stack, useTheme } from "@mui/material";
import { WebviewWindow } from "@tauri-apps/api/window"; import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSettings } from "../../contexts/SettingsContext"; import { useSettings } from "../../contexts/SettingsContext";
import { exit } from "@tauri-apps/api/process";
export const WindowButtons = () => { export const WindowButtons = () => {
// contexts // contexts
@ -24,16 +23,20 @@ export const WindowButtons = () => {
setAppWindow(appWindow); setAppWindow(appWindow);
}; };
const toggleFullscreen = async () => {
appWindow?.setFullscreen(!(await appWindow?.isFullscreen()));
};
const minimize = () => { const minimize = () => {
appWindow?.minimize(); appWindow?.minimize();
}; };
const maximize = () => { const toggleMaximize = () => {
appWindow?.toggleMaximize(); appWindow?.toggleMaximize();
}; };
const close = async () => { const close = async () => {
await exit(1); appWindow?.close();
}; };
useEffect(() => { useEffect(() => {
@ -52,6 +55,17 @@ export const WindowButtons = () => {
direction="row" direction="row"
spacing={1} spacing={1}
> >
{settings.window.fullscreen_button && (
<IconButton
onClick={toggleFullscreen}
size="small"
sx={{
backgroundColor: userTheme.palette.grey[800],
}}
>
{appWindow?.isFullscreen() ? <FullscreenExit fontSize="inherit" /> : <Fullscreen fontSize="inherit" />}
</IconButton>
)}
{settings.window.minimize_button && ( {settings.window.minimize_button && (
<IconButton <IconButton
onClick={minimize} onClick={minimize}
@ -60,23 +74,18 @@ export const WindowButtons = () => {
backgroundColor: userTheme.palette.grey[800], backgroundColor: userTheme.palette.grey[800],
}} }}
> >
<Minimize <Minimize fontSize="inherit" />
fontSize="inherit"
sx={{
backgroundColor: userTheme.palette.grey[800],
}}
/>
</IconButton> </IconButton>
)} )}
{settings.window.maximize_button && ( {settings.window.maximize_button && (
<IconButton <IconButton
onClick={maximize} onClick={toggleMaximize}
size="small" size="small"
sx={{ sx={{
backgroundColor: userTheme.palette.grey[800], backgroundColor: userTheme.palette.grey[800],
}} }}
> >
<CloseFullscreen fontSize="inherit" /> <WebAssetOutlined fontSize="inherit" />
</IconButton> </IconButton>
)} )}
<IconButton <IconButton

View file

@ -1,13 +1,16 @@
import { createContext, FC, ReactNode, useContext, useEffect, useState } from "react"; import { createContext, FC, ReactNode, useContext, useEffect, useState } from "react";
import { logcat } from "../lib/logcatService"; import { logcat } from "../lib/logcatService";
import { defaultSettings, readConfigFile, SettingsType, writeConfigFile } from "../lib/settings"; import { defaultSettings, readConfigFile, SettingsType, writeConfigFile } from "../lib/settings";
import { useAtom } from "jotai";
import { stagedSettingsAtom } from "../lib/store/jotai/settings";
// settings context // settings context
type SettingsContextProps = { type SettingsContextProps = {
fetchSettings: () => void;
resetSettings: () => void;
settings: SettingsType; settings: SettingsType;
settingsLoading: boolean; settingsLoading: boolean;
updateSettings: (updates: SettingsType) => void; updateSettings: (updates: SettingsType) => void;
resetSettings: () => void;
}; };
const SettingsContext = createContext<SettingsContextProps | undefined>(undefined); const SettingsContext = createContext<SettingsContextProps | undefined>(undefined);
@ -15,6 +18,9 @@ const SettingsContext = createContext<SettingsContextProps | undefined>(undefine
export const SettingsProvider: FC<{ children: ReactNode }> = ({ children }) => { export const SettingsProvider: FC<{ children: ReactNode }> = ({ children }) => {
logcat.log("Initializing settings...", "INFO"); logcat.log("Initializing settings...", "INFO");
// atoms
const [stagedSettings, setStagedSettings] = useAtom(stagedSettingsAtom);
// states // states
const [settings, setSettings] = useState<SettingsType>(defaultSettings); const [settings, setSettings] = useState<SettingsType>(defaultSettings);
const [settingsLoading, setSettingsLoading] = useState<boolean>(true); const [settingsLoading, setSettingsLoading] = useState<boolean>(true);
@ -22,7 +28,11 @@ export const SettingsProvider: FC<{ children: ReactNode }> = ({ children }) => {
const fetchSettings = async () => { const fetchSettings = async () => {
try { try {
const existingSettings = await readConfigFile(); const existingSettings = await readConfigFile();
// set settings state to existing settings
setSettings(existingSettings); setSettings(existingSettings);
// also update the settings atom
setStagedSettings(existingSettings);
logcat.log("Settings loaded successfully", "INFO");
} catch (error) { } catch (error) {
logcat.log(`Failed to load settings: ${error}`, "ERROR"); logcat.log(`Failed to load settings: ${error}`, "ERROR");
} finally { } finally {
@ -60,10 +70,11 @@ export const SettingsProvider: FC<{ children: ReactNode }> = ({ children }) => {
return ( return (
<SettingsContext.Provider <SettingsContext.Provider
value={{ value={{
fetchSettings,
resetSettings,
settings, settings,
settingsLoading, settingsLoading,
updateSettings, updateSettings,
resetSettings,
}} }}
> >
{children} {children}

View file

@ -15,12 +15,12 @@ export const UserThemeProvider: FC<UserThemeProviderProps> = ({ children }) => {
const userPalette = { const userPalette = {
primary: { primary: {
// light: '#a1c3f9', // light: '#a1c3f9',
main: settings.style.accent_color || "#8ab4f8", main: settings.colors.accent_color || "#8ab4f8",
// dark: '#4285f4', // dark: '#4285f4',
}, },
secondary: { secondary: {
// light: '#a1c3f9', // light: '#a1c3f9',
main: settings.style.accent_color || "#8ab4f8", main: settings.colors.accent_color || "#8ab4f8",
// dark: '#4285f4', // dark: '#4285f4',
}, },
error: { error: {
@ -60,8 +60,8 @@ export const UserThemeProvider: FC<UserThemeProviderProps> = ({ children }) => {
A700: "#616161", A700: "#616161",
}, },
background: { background: {
paper: settings.style.dark_mode ? "#303134" : "#fff", paper: settings.style.dark_mode ? settings.colors.background_color_popup : "#fff",
default: settings.style.dark_mode ? "#202124" : "#fff", default: settings.style.dark_mode ? settings.colors.background_color : "#fff",
}, },
}; };

View file

@ -9,9 +9,7 @@ export default function createEmotionCache() {
let insertionPoint; let insertionPoint;
if (isBrowser) { if (isBrowser) {
const emotionInsertionPoint = document.querySelector<HTMLMetaElement>( const emotionInsertionPoint = document.querySelector<HTMLMetaElement>('meta[name="emotion-insertion-point"]');
'meta[name="emotion-insertion-point"]',
);
insertionPoint = emotionInsertionPoint ?? undefined; insertionPoint = emotionInsertionPoint ?? undefined;
} }

View file

@ -29,9 +29,7 @@ export class LogcatServiceClass {
log(message: string, level: LogLevel = "INFO"): void { log(message: string, level: LogLevel = "INFO"): void {
const currentTimestamp = new Date().getTime(); const currentTimestamp = new Date().getTime();
const timeSinceLastLog = this.lastLogTimestamp const timeSinceLastLog = this.lastLogTimestamp ? currentTimestamp - this.lastLogTimestamp : 0;
? currentTimestamp - this.lastLogTimestamp
: 0;
const logEntry: LogEntry = { message, level, timestamp: timeSinceLastLog }; const logEntry: LogEntry = { message, level, timestamp: timeSinceLastLog };
this.logs.push(logEntry); this.logs.push(logEntry);

View file

@ -1,88 +0,0 @@
type PathsType = {
audioDirectory: string;
cacheDirectory: string;
configDirectory: string;
dataDirectory: string;
desktopDirectory: string;
documentDirectory: string;
downloadDirectory: string;
executableDirectory: string;
fontDirectory: string;
homeDirectory: string;
logDirectory: string;
pictureDirectory: string;
templateDirectory: string;
videoDirectory: string;
};
const programTrailingPath = "stort/";
class Paths {
private static instance: Paths;
paths: PathsType | null = null;
private constructor() {}
static getInstance(): Paths {
if (!Paths.instance) {
Paths.instance = new Paths();
}
return Paths.instance;
}
async initialize(): Promise<void> {
// avoid reinitialization if already doen
if (this.paths) return;
const {
audioDir,
cacheDir,
configDir,
dataDir,
desktopDir,
documentDir,
downloadDir,
executableDir,
fontDir,
homeDir,
logDir,
pictureDir,
templateDir,
videoDir,
} = await import("@tauri-apps/api/path");
try {
this.paths = {
audioDirectory: await audioDir(),
cacheDirectory: (await cacheDir()) + programTrailingPath,
configDirectory: (await configDir()) + programTrailingPath,
dataDirectory: (await dataDir()) + programTrailingPath,
desktopDirectory: await desktopDir(),
documentDirectory: await documentDir(),
downloadDirectory: await downloadDir(),
executableDirectory: await executableDir(),
fontDirectory: await fontDir(),
homeDirectory: await homeDir(),
logDirectory: await logDir(),
pictureDirectory: await pictureDir(),
templateDirectory: await templateDir(),
videoDirectory: await videoDir(),
};
// make paths immutable
Object.freeze(this.paths);
} catch (error) {
console.error("Error initializing paths:", error);
throw error;
}
}
getPath(key: keyof PathsType): string {
if (!this.paths) {
throw new Error("Paths are not initialized. Call initialize() first.");
}
return this.paths[key];
}
}
export default Paths.getInstance();

View file

@ -1,23 +1,29 @@
import { merge } from "lodash"; import { merge } from "lodash";
import Paths from "./path";
import { readTomlFile, writeTomlFile } from "./utils/toml"; import { readTomlFile, writeTomlFile } from "./utils/toml";
export const defaultSettings = { export const defaultSettings = {
background: { background: {
background_color: "#202124" as string,
background_image_path: "" as string, background_image_path: "" as string,
}, },
style: { colors: {
accent_color: "#8ab4f8" as string, accent_color: "#8ab4f8" as string,
background_color: "#202124" as string,
background_color_popup: "#303134" as string,
footer_color: "#000000" as string,
},
style: {
blur_radius: 10 as number,
dark_mode: true as boolean, dark_mode: true as boolean,
font_family: "monospace" as string, font_family: "monospace" as string,
font_scaling: 100, font_scaling: 100,
opacity: 0.8 as number,
radius: 8 as number, radius: 8 as number,
transition_duration: 200 as number, transition_duration: 200 as number,
window_height: 80 as number, window_height: 80 as number,
window_width: 400 as number, window_width: 500 as number,
}, },
window: { window: {
fullscreen_button: false as boolean,
maximize_button: false as boolean, maximize_button: false as boolean,
minimize_button: false as boolean, minimize_button: false as boolean,
start_fullscreen: false as boolean, // TODO: this should be true on prod start_fullscreen: false as boolean, // TODO: this should be true on prod
@ -30,9 +36,8 @@ export const readConfigFile = async (): Promise<SettingsType> => {
let existingData: SettingsType = defaultSettings; let existingData: SettingsType = defaultSettings;
try { try {
// try to read the config file const { appConfigDir } = await import("@tauri-apps/api/path");
await Paths.initialize(); const data = await readTomlFile<SettingsType>((await appConfigDir()) + "/config.toml");
const data = await readTomlFile<SettingsType>(Paths.getPath("configDirectory") + "config.toml");
if (data) { if (data) {
existingData = data; existingData = data;
console.log("existing data"); console.log("existing data");
@ -52,6 +57,7 @@ export const readConfigFile = async (): Promise<SettingsType> => {
}; };
export const writeConfigFile = async (settingsValues: SettingsType): Promise<void> => { export const writeConfigFile = async (settingsValues: SettingsType): Promise<void> => {
await writeTomlFile<SettingsType>(Paths.getPath("configDirectory") + "config.toml", settingsValues); const { appConfigDir } = await import("@tauri-apps/api/path");
await writeTomlFile<SettingsType>((await appConfigDir()) + "/config.toml", settingsValues);
console.debug("Settings file written successfully."); console.debug("Settings file written successfully.");
}; };

View file

@ -1,4 +1,4 @@
import { createDir } from "@tauri-apps/api/fs"; import { createDir } from "@tauri-apps/plugin-fs";
export const testing = async () => { export const testing = async () => {
await createDir("/home/vomitblood/.config/stort/"); await createDir("/home/vomitblood/.config/stort/");

26
src/lib/utils/color.ts Normal file
View file

@ -0,0 +1,26 @@
export const hexToRgba = (hex: string): { r: number; g: number; b: number; a: number } => {
// remove the hash at the start if it is there
hex = hex.replace("#", "");
let r, g, b, a;
if (hex.length === 8) {
// hex have alpha (rrggbbaa)
r = parseInt(hex.substring(0, 2), 16);
g = parseInt(hex.substring(2, 4), 16);
b = parseInt(hex.substring(4, 6), 16);
// convert alpha (from 0-255) to decimal (from 0-1)
a = parseInt(hex.substring(6, 8), 16) / 255;
} else if (hex.length === 6) {
// hex no have alpha
r = parseInt(hex.substring(0, 2), 16);
g = parseInt(hex.substring(2, 4), 16);
b = parseInt(hex.substring(4, 6), 16);
// default alpha is 1
a = 1;
} else {
throw new Error("Invalid hex color format. Must be #rrggbbaa or #rrggbb.");
}
return { r, g, b, a };
};

View file

@ -1,4 +1,4 @@
import { readTextFile, writeTextFile } from "@tauri-apps/api/fs"; import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
export const readJsonFile = async <T>(path: string): Promise<T | null> => { export const readJsonFile = async <T>(path: string): Promise<T | null> => {
try { try {

View file

@ -1,4 +1,4 @@
import { readTextFile, writeTextFile } from "@tauri-apps/api/fs"; import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
import { parse, stringify } from "smol-toml"; import { parse, stringify } from "smol-toml";
export const readTomlFile = async <T>(path: string): Promise<T | null> => { export const readTomlFile = async <T>(path: string): Promise<T | null> => {

View file

@ -2,7 +2,6 @@ import { CacheProvider, EmotionCache } from "@emotion/react";
import { CssBaseline } from "@mui/material"; import { CssBaseline } from "@mui/material";
import { AppProps } from "next/app"; import { AppProps } from "next/app";
import Head from "next/head"; import Head from "next/head";
import { Initialization } from "../components/Generic/Initialization";
import { UserThemeProvider } from "../contexts/ThemeContext"; import { UserThemeProvider } from "../contexts/ThemeContext";
import createEmotionCache from "../lib/createEmotionCache"; import createEmotionCache from "../lib/createEmotionCache";
import "../styles/global.css"; import "../styles/global.css";
@ -22,14 +21,13 @@ export default function MyApp(props: MyAppProps) {
<Head> <Head>
<title>Stort</title> <title>Stort</title>
<meta <meta
name='viewport' name="viewport"
content='initial-scale=1, width=device-width' content="initial-scale=1, width=device-width"
/> />
</Head> </Head>
<SettingsProvider> <SettingsProvider>
<UserThemeProvider> <UserThemeProvider>
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
<Initialization />
<CssBaseline /> <CssBaseline />
<Component {...pageProps} /> <Component {...pageProps} />
</UserThemeProvider> </UserThemeProvider>

View file

@ -1,10 +1,9 @@
import { BugReport } from "@mui/icons-material"; import { BugReport } from "@mui/icons-material";
import { Box, Button, IconButton, TextField, Typography } from "@mui/material"; import { Box, IconButton, TextField, Typography } from "@mui/material";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useState } from "react"; import { useState } from "react";
import { SettingsItem } from "../components/HeaderBar/Settings/SettingsItem"; import { SettingsItem } from "../components/HeaderBar/Settings/SettingsItem";
import { useSettings } from "../contexts/SettingsContext"; import { useSettings } from "../contexts/SettingsContext";
import { testing } from "../lib/testing";
export default function Testing() { export default function Testing() {
// contexts // contexts
@ -24,28 +23,29 @@ export default function Testing() {
> >
<BugReport /> <BugReport />
</IconButton> </IconButton>
<Button <button
onClick={() => { onClick={() => {
resetSettings(); resetSettings();
}} }}
> >
reset settings reset settings
</Button> </button>
<Button>update settings</Button> <button>update settings</button>
<Button <button
onClick={() => { onClick={() => {
console.log(settings); console.log(settings);
}} }}
> >
log settings log settings
</Button> </button>
<Button <button
onClick={() => { onClick={() => {
console.log(testing()); console.log(settings.background.background_image_path);
}} }}
> >
testing testing
</Button> </button>
<Typography>{text}</Typography> <Typography>{text}</Typography>
<SettingsItem <SettingsItem
defaultText="#8ab4f8" defaultText="#8ab4f8"

View file

@ -16,6 +16,9 @@
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "incremental": true,
"noImplicitAny": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",