├── .github
├── changelog-configuration.json
└── workflows
│ ├── nightly.yml
│ └── publish.yml
├── .gitignore
├── .scripts
└── mirror-latest-json.js
├── Cargo.lock
├── Cargo.toml
├── LICENSE
├── README.md
├── README.zh-CN.md
├── biome.json
├── bun.lockb
├── bunfig.toml
├── daemon
├── Cargo.toml
├── build.rs
└── src
│ ├── color_mode.rs
│ ├── config.rs
│ ├── error.rs
│ ├── lazy.rs
│ ├── lib.rs
│ ├── log.rs
│ ├── main.rs
│ ├── monitor
│ ├── device_interface.rs
│ ├── display_config.rs
│ ├── error.rs
│ ├── mod.rs
│ ├── monitor_info.rs
│ └── wallpaper_manager.rs
│ ├── position
│ ├── geolocation_access.rs
│ ├── mod.rs
│ ├── position_manager.rs
│ ├── position_types.rs
│ └── windows_api_helper.rs
│ ├── registry.rs
│ ├── solar.rs
│ ├── theme
│ ├── manager.rs
│ ├── mod.rs
│ ├── processor.rs
│ └── validator.rs
│ └── utils
│ ├── mod.rs
│ └── string.rs
├── images
├── home.avif
└── settings.avif
├── index.html
├── package.json
├── src-tauri
├── .gitignore
├── Cargo.toml
├── build.rs
├── capabilities
│ └── default.json
├── icons
│ ├── 128x128.png
│ ├── 128x128@2x.png
│ ├── 32x32.png
│ ├── Square107x107Logo.png
│ ├── Square142x142Logo.png
│ ├── Square150x150Logo.png
│ ├── Square284x284Logo.png
│ ├── Square30x30Logo.png
│ ├── Square310x310Logo.png
│ ├── Square44x44Logo.png
│ ├── Square71x71Logo.png
│ ├── Square89x89Logo.png
│ ├── StoreLogo.png
│ ├── android
│ │ ├── mipmap-hdpi
│ │ │ ├── ic_launcher.png
│ │ │ ├── ic_launcher_foreground.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-mdpi
│ │ │ ├── ic_launcher.png
│ │ │ ├── ic_launcher_foreground.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-xhdpi
│ │ │ ├── ic_launcher.png
│ │ │ ├── ic_launcher_foreground.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-xxhdpi
│ │ │ ├── ic_launcher.png
│ │ │ ├── ic_launcher_foreground.png
│ │ │ └── ic_launcher_round.png
│ │ └── mipmap-xxxhdpi
│ │ │ ├── ic_launcher.png
│ │ │ ├── ic_launcher_foreground.png
│ │ │ └── ic_launcher_round.png
│ ├── icon.icns
│ ├── icon.ico
│ ├── icon.png
│ └── ios
│ │ ├── AppIcon-20x20@1x.png
│ │ ├── AppIcon-20x20@2x-1.png
│ │ ├── AppIcon-20x20@2x.png
│ │ ├── AppIcon-20x20@3x.png
│ │ ├── AppIcon-29x29@1x.png
│ │ ├── AppIcon-29x29@2x-1.png
│ │ ├── AppIcon-29x29@2x.png
│ │ ├── AppIcon-29x29@3x.png
│ │ ├── AppIcon-40x40@1x.png
│ │ ├── AppIcon-40x40@2x-1.png
│ │ ├── AppIcon-40x40@2x.png
│ │ ├── AppIcon-40x40@3x.png
│ │ ├── AppIcon-512@2x.png
│ │ ├── AppIcon-60x60@2x.png
│ │ ├── AppIcon-60x60@3x.png
│ │ ├── AppIcon-76x76@1x.png
│ │ ├── AppIcon-76x76@2x.png
│ │ └── AppIcon-83.5x83.5@2x.png
├── src
│ ├── auto_start.rs
│ ├── cache
│ │ ├── cache_manager.rs
│ │ ├── cleanup_service.rs
│ │ ├── error.rs
│ │ ├── fs_service.rs
│ │ ├── http_service.rs
│ │ └── mod.rs
│ ├── download
│ │ ├── downloader.rs
│ │ ├── error.rs
│ │ ├── extractor.rs
│ │ ├── file_manager.rs
│ │ ├── http_service.rs
│ │ ├── mod.rs
│ │ └── task_manager.rs
│ ├── error.rs
│ ├── fs.rs
│ ├── i18n
│ │ ├── keys.rs
│ │ ├── locales
│ │ │ ├── en_us.rs
│ │ │ ├── ja_jp.rs
│ │ │ ├── mod.rs
│ │ │ ├── zh_cn.rs
│ │ │ ├── zh_hk.rs
│ │ │ └── zh_tw.rs
│ │ ├── mod.rs
│ │ └── utils.rs
│ ├── main.rs
│ ├── monitor.rs
│ ├── postion.rs
│ ├── process_manager.rs
│ ├── setup.rs
│ ├── theme.rs
│ └── window.rs
├── tauri.conf.json
├── tauri.debug.conf.json
└── windows
│ └── hooks.nsi
├── src
├── App.css.ts
├── App.tsx
├── commands
│ ├── autostart.ts
│ ├── config.ts
│ ├── index.ts
│ ├── monitor.ts
│ ├── system.ts
│ ├── theme.ts
│ ├── translation.ts
│ └── window.ts
├── components
│ ├── DangerButton
│ │ ├── DangerButton.css.ts
│ │ ├── DangerButton.tsx
│ │ └── index.ts
│ ├── Dialog
│ │ ├── Dialog.css.ts
│ │ ├── Dialog.tsx
│ │ └── index.ts
│ ├── Download
│ │ ├── Download.css.ts
│ │ ├── Download.tsx
│ │ └── index.ts
│ ├── Flex
│ │ ├── Flex.css.ts
│ │ ├── Flex.tsx
│ │ └── index.ts
│ ├── Image.tsx
│ ├── ImageCarousel
│ │ ├── ImageCarousel.css.ts
│ │ ├── ImageCarousel.tsx
│ │ └── index.ts
│ ├── NumericInput
│ │ ├── NumericInput
│ │ │ ├── NumericInput.css.ts
│ │ │ ├── NumericInput.tsx
│ │ │ ├── NumericInput.types.ts
│ │ │ ├── index.ts
│ │ │ └── useNumericInput.ts
│ │ ├── NumericInputContainer
│ │ │ ├── NumericInputContainer.css.ts
│ │ │ ├── NumericInputContainer.tsx
│ │ │ └── index.ts
│ │ └── index.ts
│ ├── Select
│ │ ├── Select
│ │ │ ├── Select.css.ts
│ │ │ ├── Select.tsx
│ │ │ ├── Select.types.ts
│ │ │ └── index.ts
│ │ ├── SelectContainer
│ │ │ ├── SelectContainer.css.ts
│ │ │ ├── SelectContainer.tsx
│ │ │ ├── SelectContainer.types.ts
│ │ │ └── index.ts
│ │ └── index.ts
│ ├── Settings
│ │ ├── AutoDetectColorMode.tsx
│ │ ├── AutoStart.tsx
│ │ ├── CoordinateSource.tsx
│ │ ├── Footer.tsx
│ │ ├── GithubMirror.tsx
│ │ ├── Interval.tsx
│ │ ├── Item
│ │ │ ├── SettingsItem.css.ts
│ │ │ ├── SettingsItem.tsx
│ │ │ └── index.ts
│ │ ├── LockScreenWallpaperSwitch.tsx
│ │ ├── ThemesDirectory.tsx
│ │ └── index.tsx
│ ├── Sidebar
│ │ ├── Sidebar.css.ts
│ │ ├── Sidebar.tsx
│ │ ├── SidebarButtons.css.ts
│ │ ├── SidebarButtons.tsx
│ │ └── index.ts
│ ├── ThemeActions.tsx
│ ├── ThemeMenu
│ │ ├── ThemeMenu.css.ts
│ │ ├── ThemeMenu.tsx
│ │ └── index.ts
│ ├── ThemeShowcase.tsx
│ └── Update
│ │ ├── UpdateDialog.tsx
│ │ └── index.tsx
├── contexts
│ ├── ConfigContext.tsx
│ ├── MonitorContext.tsx
│ ├── SettingsContext.tsx
│ ├── TaskContext.tsx
│ ├── ThemeContext.tsx
│ ├── TranslationsContext.tsx
│ ├── UpdateContext.tsx
│ └── index.tsx
├── hooks
│ ├── __tests__
│ │ └── usePausableTimeout.test.ts
│ ├── monitor
│ │ ├── index.ts
│ │ ├── useMonitorSelection.tsx
│ │ └── useMonitorThemeSync.tsx
│ ├── state
│ │ ├── index.tsx
│ │ ├── useConfigState.tsx
│ │ └── useSettingsState.tsx
│ ├── theme
│ │ ├── useThemeApplication.tsx
│ │ ├── useThemeSelection.tsx
│ │ └── useThemeState.tsx
│ ├── useAppInitialization.tsx
│ ├── useColorMode.tsx
│ ├── useDark.ts
│ ├── useLocationPermission.tsx
│ ├── usePausableTimeout.ts
│ ├── useTaskManager.tsx
│ └── useUpdateManager.tsx
├── index.css.ts
├── index.tsx
├── lazy.tsx
├── themes.ts
├── themes
│ └── vars.css.ts
├── utils
│ ├── array.ts
│ ├── color.ts
│ ├── i18n.ts
│ └── proxy.ts
└── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
├── types
├── config.d.ts
├── context.d.ts
├── i18n.d.ts
├── monitor.d.ts
└── theme.d.ts
└── vite.config.ts
/.github/changelog-configuration.json:
--------------------------------------------------------------------------------
1 | {
2 | "categories": [
3 | {
4 | "title": "## 🎉 Features",
5 | "labels": ["feature"]
6 | },
7 | {
8 | "title": "## ⚙️ Refactors",
9 | "labels": ["refactor"]
10 | },
11 | {
12 | "title": "## 🚀 Enhancements",
13 | "labels": ["enhancement"]
14 | },
15 | {
16 | "title": "## 🐛 Fixes",
17 | "labels": ["fix", "bug"]
18 | },
19 | {
20 | "title": "## 📦 Dependencies",
21 | "labels": ["dependencies"]
22 | },
23 | {
24 | "title": "## 🎨 Styles",
25 | "labels": ["style"]
26 | },
27 | {
28 | "title": "## 🛠 Breaking Changes",
29 | "labels": ["semver-major", "breaking-change"]
30 | }
31 | ],
32 | "pr_template": "- #{{TITLE}}",
33 | "base_branches": ["main"]
34 | }
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .DS_Store
4 | *.bak
5 | target
6 |
--------------------------------------------------------------------------------
/.scripts/mirror-latest-json.js:
--------------------------------------------------------------------------------
1 | import fs from "node:fs";
2 | import path from "node:path";
3 |
4 | const latestJsonContent = fs.readFileSync(path.join("..", "latest.json"), {
5 | encoding: "utf8",
6 | });
7 |
8 | const mirrors = [
9 | { host: "gh-proxy.com", prefix: true },
10 | { host: "kkgithub.com" },
11 | ];
12 |
13 | const GITHUB = "https://github.com/";
14 |
15 | const mirrorContent = (mirror) => {
16 | if (mirror.prefix) {
17 | return latestJsonContent.replaceAll(
18 | GITHUB,
19 | `https://${mirror.host}/https://github.com/`
20 | );
21 | }
22 |
23 | return latestJsonContent.replaceAll(GITHUB, `https://${mirror.host}/`);
24 | };
25 |
26 | const newMirrorJSON = (mirror, filepath) => {
27 | const content = mirrorContent(mirror);
28 | fs.writeFileSync(filepath, content);
29 | };
30 |
31 | const run = async () => {
32 | const currentDir = process.cwd();
33 | const targetDir = path.join(currentDir, "mirrors");
34 |
35 | if (!fs.existsSync(targetDir)) {
36 | fs.mkdirSync(targetDir);
37 | }
38 |
39 | mirrors.forEach((m, i) =>
40 | newMirrorJSON(m, path.join(targetDir, `latest-mirror-${i + 1}.json`))
41 | );
42 | };
43 |
44 | run();
45 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [workspace]
2 | resolver = "2"
3 | members = ["daemon", "src-tauri"]
4 |
5 | [workspace.package]
6 | authors = ["thep0y"]
7 | version = "0.1.24"
8 | # homepage = ""
9 | repository = "https://github.com/dwall-rs/dwall"
10 | license = "AGPL-3.0"
11 | edition = "2021"
12 |
13 | [workspace.dependencies]
14 | dirs = { version = "6", default-features = false }
15 | thiserror = { version = "2", default-features = false }
16 | serde = { version = "1", default-features = false, features = ["derive"] }
17 | serde_json = { version = "1", default-features = false }
18 | tracing = { version = "0", default-features = false, features = ["log"] }
19 | tracing-subscriber = { version = "0", default-features = false, features = [
20 | "fmt",
21 | 'time',
22 | "local-time",
23 | 'env-filter',
24 | 'json',
25 | ] }
26 | tokio = { version = "1", default-features = false }
27 | windows = { version = "0.61", default-features = false }
28 |
29 | [profile.release]
30 | panic = "abort"
31 | codegen-units = 1
32 | lto = true
33 | incremental = false
34 | opt-level = "s"
35 | strip = true
36 |
--------------------------------------------------------------------------------
/README.zh-CN.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | English | 简体中文
9 |
10 |
11 | # DWALL
12 |
13 | DWALL 是一款轻量级应用程序,旨在模拟 macOS 壁纸切换行为,通过基于太阳高度角和方位角动态更改 Windows 系统上的桌面背景。体验随着一天中太阳自然移动而无缝过渡的壁纸变化。
14 |
15 | ## 动机
16 |
17 | 面对现有的解决方案如 [WinDynamicDesktop](https://github.com/t1m0thyj/WinDynamicDesktop) 和 [AutoDarkMode](https://github.com/AutoDarkMode/Windows-Auto-Night-Mode),您可能会疑惑为何需要另一个工具。虽然这些应用程序提供类似的功能,但它们作为独立项目运行,没有整合计划。此外,它们的资源消耗可能高于此类任务的理想水平。
18 |
19 | 认识到这一差距,DWALL 被开发为一种高效的替代方案。它可以根据太阳位置自动切换明暗主题并更改壁纸,同时在运行期间保持最小的内存使用量。对于寻求资源占用更少选项的用户,DWALL 提供了一个极具吸引力的选择。
20 |
21 | ## 特性
22 |
23 | - **基于太阳的调度:** 自动根据太阳路径调整壁纸。
24 | - **低资源占用:** 针对性能进行优化,对系统资源的影响最小。
25 | - **无缝集成:** 轻松融入您的工作流程,没有侵入性通知或设置。
26 |
27 | ## 用前须知
28 |
29 | 本项目仍然处于开发阶段,可能存在一些问题。如果您在使用过程中遇到任何问题,请随时在 GitHub 页面上提出问题。
30 |
31 | > [!WARNING]
32 | > 本程序可能无法在精简版操作系统上运行,如果你使用的是精简版 Windows 操作系统,遇到错误时不需要提交 issue,安装官方 Windows 操作系统后再尝试运行本程序。
33 |
34 | ## 使用步骤
35 |
36 | 1. 在 [releases](https://github.com/dwall-rs/dwall/releases/latest) 中下载最新的 DWALL 可执行文件。
37 | 2. 运行 DWALL 可执行文件。
38 | 3. 允许 DWALL 访问您的位置信息,或者在设置页面手动设置您的位置。
39 | 4. 在侧边菜单中点击你想使用的壁纸,如果未下载,需要先点击“下载”按钮下载这套壁纸,如果下载完成则可以点击“应用”按钮使用这套壁纸。
40 | 5. 如果你使用多个显示器,可以在显示器选择器中选择你想单独配置的显示器,然后重复第 4 步的操作。
41 |
42 | ## 截图
43 |
44 | 以下是展示 DWALL 运行效果的两张截图:
45 |
46 | 
47 |
48 | 
49 |
50 | ## 运行日志
51 |
52 | DWALL 的日志等级默认为`warning`,您可以通过设置环境变量`DWALL_LOG`来调整日志等级,如`DWALL_LOG=info`,需要注意的是,release 版本不会输出`info`以下的日志。
53 |
54 | ## 常见问题
55 |
56 | ### 1. 为什么设置和守护进程完全隔离?
57 |
58 | 最初的版本中,设置和守护进程是在同一个进程中运行的,同时支持通过任务栏托盘管理,但这会使得进程的内存占用较大(相比于其他同类型程序仍然要小很多),这不是我想要的结果,所以我设置和守护进程完全隔离,这样可以减少守护进程的内存占用,同时也使得进程的管理更加简单。
59 |
60 | 当然,这也带来了进程间通信的问题,目前的版本,设置进程(图形化程序)和守护进程没有实现进程间通信,二者交流的唯一途径是配置文件,这就会导致当守护进程异常退出时,设置进程无法及时获取守护进程的状态,这是一个需要解决的问题。
61 |
62 | ### 2. 壁纸下载失败。
63 |
64 | 壁纸资源保存在 github 中,但一些国家和地区因为网络管制无法正常访问 github,如果不设置 Github 镜像模板,就会导致壁纸下载失败。你需要通过搜索引擎自行搜索可用的 Github 镜像站或加速站。假设你搜索到了一个可用的 github 加速站`https://ghproxy.cc`,可在设置页面中如下配置 Github 镜像模板:
65 |
66 | ```
67 | https://ghproxy.cc/https://github.com///releases/download//
68 | ```
69 |
70 | 如果你搜索到到的是一个 github 镜镜站`https://kkgithub.com/`,则需要如下配置 Github 镜像模板:
71 |
72 | ```
73 | https://kkgithub.com///releases/download//
74 | ```
75 |
76 | 然后就可以正常下载壁纸了。
77 |
78 | ### 3. 为什么不支持自定义壁纸?
79 |
80 | 因为壁纸需要与太阳位置相关联,而大部分用户没有相关的天文学知识,无法将壁纸与太阳位置完美匹配,因此,我们提供一些已经与太阳位置完美关联的壁纸,用户可以根据自己的喜好选择。
81 |
82 | ### 4. 我能设计与太阳位置完美匹配的成套壁纸,如何添加到 DWALL 中?
83 |
84 | 你可以将制作好的壁纸上传到网盘当中,创建一个 issue 并说明设计思路,我们会在合适的时机将你的壁纸添加到 DWALL 中。
85 |
86 | ### 5. 缩略图为什么加载失败?
87 |
88 | 缩略图使用的是 Github 直链,部分地区对 Github 有网络管制,导致无法加载,你可以通过设置 Github 镜像模板来解决这个问题。
89 |
90 | ### 6. 为什么不使用托盘管理后台进程?
91 |
92 | 与第 1 个问题相同,托盘进程本身要比后台进程的内存占用还要大,考虑到这一点,我决定不使用托盘管理后台进程。
93 |
94 | ---
95 |
96 | 我们欢迎社区的贡献,以帮助改进 DWALL。如果您遇到任何问题或对新功能有建议,请随时在我们的 GitHub 页面上提出问题或提交拉取请求。
97 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.7.0/schema.json",
3 | "organizeImports": {
4 | "enabled": false
5 | },
6 | "formatter": {
7 | "indentStyle": "space",
8 | "indentWidth": 2
9 | },
10 | "linter": {
11 | "enabled": true,
12 | "rules": {
13 | "recommended": true,
14 | "correctness": {
15 | "useJsxKeyInIterable": "off"
16 | },
17 | "a11y": {
18 | "useKeyWithClickEvents": "off"
19 | },
20 | "style": {
21 | "noNonNullAssertion": "off"
22 | }
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/bun.lockb
--------------------------------------------------------------------------------
/bunfig.toml:
--------------------------------------------------------------------------------
1 | [install]
2 | registry = "https://registry.npmmirror.com"
3 |
--------------------------------------------------------------------------------
/daemon/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "dwall"
3 | description = "Dwall daemon"
4 | exclude = ["CHANGELOG.md", "/target"]
5 | readme = "README.md"
6 | version.workspace = true
7 | authors.workspace = true
8 | edition.workspace = true
9 | # homepage.workspace = true
10 | repository.workspace = true
11 | license.workspace = true
12 |
13 | [build-dependencies]
14 | winresource = { version = "0", optional = true }
15 |
16 | [dependencies]
17 | serde_valid = "1"
18 | time = { version = "0", default-features = false, features = [
19 | 'macros',
20 | 'serde',
21 | ] }
22 | toml = { version = "0", default-features = false, features = [
23 | "display",
24 | "parse",
25 | ] }
26 | tokio = { workspace = true, features = [
27 | "sync",
28 | "macros",
29 | "time",
30 | "fs",
31 | "rt",
32 | "rt-multi-thread",
33 | ] }
34 | regex = { version = "1", default-features = false }
35 | dirs = { workspace = true }
36 | serde = { workspace = true }
37 | serde_json = { workspace = true }
38 | thiserror = { workspace = true }
39 | tracing = { workspace = true }
40 | tracing-subscriber = { workspace = true }
41 | windows = { workspace = true, default-features = false, features = [
42 | "std",
43 | "Devices_Geolocation",
44 | "Win32_System_Registry",
45 | "System_UserProfile",
46 | "Storage_Streams",
47 | "Win32_UI",
48 | "Win32_UI_Shell",
49 | "Win32_UI_WindowsAndMessaging",
50 | "Win32_System_Com",
51 | "Win32_Graphics",
52 | "Win32_Graphics_Gdi",
53 | "Win32_Devices_Display",
54 | "Win32_Devices_DeviceAndDriverInstallation",
55 | ] }
56 |
57 | [features]
58 | default = []
59 | log-color = ["tracing-subscriber/ansi"]
60 | build-script = ["winresource"]
61 | log-max-level-info = ["tracing/release_max_level_info"]
62 |
--------------------------------------------------------------------------------
/daemon/build.rs:
--------------------------------------------------------------------------------
1 | use std::io;
2 |
3 | fn main() -> io::Result<()> {
4 | #[cfg(feature = "build-script")]
5 | {
6 | use {std::env, winresource::WindowsResource};
7 |
8 | if cfg!(not(feature = "log-max-level-info")) {
9 | println!("cargo:rustc-env=DWALL_LOG=debug");
10 | };
11 |
12 | if env::var_os("CARGO_CFG_WINDOWS").is_some() {
13 | WindowsResource::new()
14 | .set_icon("../src-tauri/icons/icon.ico")
15 | .set(
16 | "LegalCopyright",
17 | "Copyright (C) 2025 thep0y. All rights reserved.",
18 | )
19 | .compile()?;
20 | }
21 | }
22 | Ok(())
23 | }
24 |
--------------------------------------------------------------------------------
/daemon/src/error.rs:
--------------------------------------------------------------------------------
1 | use crate::registry::RegistryError;
2 |
3 | /// Application result type, used for unified error handling
4 | pub type DwallResult = std::result::Result;
5 |
6 | /// Application global error type
7 | ///
8 | /// Contains all possible error types that may occur during application execution
9 | #[derive(Debug, thiserror::Error)]
10 | pub enum DwallError {
11 | /// Input/Output error
12 | #[error("IO operation failed: {0}")]
13 | Io(#[from] std::io::Error),
14 |
15 | /// Windows API error
16 | #[error("Windows system call failed: {0}")]
17 | Windows(#[from] windows::core::Error),
18 |
19 | /// Theme-related error
20 | #[error("Theme processing error: {0}")]
21 | Theme(#[from] crate::theme::ThemeError),
22 |
23 | /// JSON serialization/deserialization error
24 | #[error("JSON processing failed: {0}")]
25 | SerdeJson(#[from] serde_json::Error),
26 |
27 | /// Configuration-related error
28 | #[error("Configuration error: {0}")]
29 | Config(#[from] crate::config::ConfigError),
30 |
31 | /// Registry related error
32 | #[error("Registry error: {0}")]
33 | Registry(#[from] RegistryError),
34 |
35 | /// Null character error
36 | #[error("String contains null character: {0}")]
37 | NulError(#[from] std::ffi::NulError),
38 |
39 | /// Time offset error
40 | #[error("Unable to determine time offset: {0}")]
41 | TimeIndeterminateOffset(#[from] time::error::IndeterminateOffset),
42 |
43 | /// Monitor related error
44 | #[error("Monitor operation failed: {0}")]
45 | Monitor(#[from] crate::monitor::error::MonitorError),
46 |
47 | /// Position related error
48 | #[error("Position error: {0}")]
49 | Position(#[from] crate::position::PositionError),
50 |
51 | /// Geolocation access error
52 | #[error("Geolocation access error: {0}")]
53 | GeolocationAccess(#[from] crate::position::GeolocationAccessError),
54 |
55 | /// Timeout error
56 | #[error("Operation timed out: {0}")]
57 | Timeout(String),
58 |
59 | /// Timeout error
60 | #[error("Operation timed out: {0}")]
61 | MonitorWallpaperManagerError(#[from] crate::monitor::MonitorWallpaperManagerError),
62 | }
63 |
--------------------------------------------------------------------------------
/daemon/src/lazy.rs:
--------------------------------------------------------------------------------
1 | use std::{fs, path::PathBuf, sync::LazyLock};
2 |
3 | pub static DWALL_CONFIG_DIR: LazyLock = LazyLock::new(|| {
4 | let config_dir = dirs::config_dir().unwrap();
5 |
6 | let app_config_dir = config_dir.join("dwall");
7 |
8 | if !app_config_dir.exists() {
9 | if let Err(e) = fs::create_dir(&app_config_dir) {
10 | error!(error = ?e, "Failed to create config directory");
11 | panic!("Failed to create config directory: {}", e);
12 | } else {
13 | info!(path = %app_config_dir.display(), "Config directory created successfully");
14 | }
15 | } else {
16 | debug!(path = %app_config_dir.display(), "Config directory already exists");
17 | }
18 |
19 | app_config_dir
20 | });
21 |
22 | pub static DWALL_CACHE_DIR: LazyLock = LazyLock::new(|| {
23 | let config_dir = dirs::cache_dir().unwrap();
24 |
25 | let dir = config_dir.join("com.thep0y.dwall"); // bundle identifier
26 | trace!(path = %dir.display(), "Initializing cache directory");
27 |
28 | if !dir.exists() {
29 | if let Err(e) = fs::create_dir(&dir) {
30 | error!(error = ?e, "Failed to create cache directory");
31 | panic!("Failed to create cache directory: {}", e);
32 | } else {
33 | info!("Cache directory created successfully at: {}", dir.display());
34 | }
35 | } else {
36 | debug!(path = %dir.display(), "Cache directory already exists");
37 | }
38 |
39 | dir
40 | });
41 |
--------------------------------------------------------------------------------
/daemon/src/lib.rs:
--------------------------------------------------------------------------------
1 | mod color_mode;
2 | pub mod config;
3 | pub mod error;
4 | mod lazy;
5 | mod log;
6 | pub mod monitor;
7 | pub mod position;
8 | pub mod registry;
9 | mod solar;
10 | mod theme;
11 | pub mod utils;
12 |
13 | #[macro_use]
14 | extern crate tracing;
15 |
16 | pub use color_mode::ColorMode;
17 | pub use error::{DwallError, DwallResult};
18 | pub use lazy::{DWALL_CACHE_DIR, DWALL_CONFIG_DIR};
19 | pub use log::setup_logging;
20 | pub use theme::{apply_theme, ThemeValidator};
21 |
--------------------------------------------------------------------------------
/daemon/src/log.rs:
--------------------------------------------------------------------------------
1 | use std::{env, str::FromStr, sync::Mutex};
2 |
3 | use time::{format_description::BorrowedFormatItem, macros::format_description};
4 | use tracing::Level;
5 | use tracing_subscriber::{
6 | fmt::{time::LocalTime, writer::BoxMakeWriter},
7 | EnvFilter,
8 | };
9 |
10 | /// Get default log level
11 | fn default_log_level() -> Level {
12 | if cfg!(debug_assertions) {
13 | Level::TRACE
14 | } else {
15 | Level::WARN
16 | }
17 | }
18 |
19 | /// Get log level from environment variable
20 | fn get_log_level() -> Level {
21 | env::var("DWALL_LOG")
22 | .ok()
23 | .as_deref()
24 | .or(option_env!("DWALL_LOG"))
25 | .and_then(|level| Level::from_str(level).ok())
26 | .unwrap_or_else(default_log_level)
27 | }
28 |
29 | /// Create environment filter for logging
30 | fn create_env_filter>(pkg_names: &[S], level: Level) -> EnvFilter {
31 | let filter_str = pkg_names
32 | .iter()
33 | .map(|s| format!("{}={}", s.as_ref(), level))
34 | .collect::>()
35 | .join(",");
36 |
37 | EnvFilter::builder()
38 | .with_default_directive(level.into())
39 | .parse_lossy(filter_str)
40 | }
41 |
42 | /// Get time format based on build configuration
43 | fn get_time_format<'a>() -> &'a [BorrowedFormatItem<'a>] {
44 | if cfg!(debug_assertions) {
45 | format_description!("[hour]:[minute]:[second].[subsecond digits:3]")
46 | } else {
47 | format_description!("[year]-[month]-[day] [hour]:[minute]:[second].[subsecond digits:3]")
48 | }
49 | }
50 |
51 | /// Setup logging with given configuration
52 | pub fn setup_logging>(pkg_names: &[S]) {
53 | let timer = LocalTime::new(get_time_format());
54 | let level = get_log_level();
55 |
56 | let writer = if cfg!(debug_assertions) {
57 | BoxMakeWriter::new(Mutex::new(std::io::stderr()))
58 | } else {
59 | use crate::lazy::DWALL_CONFIG_DIR;
60 | use std::fs::File;
61 |
62 | let log_file =
63 | File::create(DWALL_CONFIG_DIR.join(format!("{}.log", pkg_names[0].as_ref())))
64 | .expect("Failed to create the log file");
65 | BoxMakeWriter::new(Mutex::new(log_file))
66 | };
67 |
68 | let builder = tracing_subscriber::fmt()
69 | .with_file(cfg!(debug_assertions))
70 | .with_target(!cfg!(debug_assertions))
71 | .with_line_number(true)
72 | .with_env_filter(create_env_filter(pkg_names, level))
73 | .with_timer(timer)
74 | .with_writer(writer);
75 |
76 | if cfg!(debug_assertions) {
77 | builder.with_ansi(true).init();
78 | } else {
79 | builder.json().init();
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/daemon/src/main.rs:
--------------------------------------------------------------------------------
1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
2 |
3 | use dwall::{apply_theme, config::read_config_file, setup_logging};
4 |
5 | #[tokio::main]
6 | async fn main() -> dwall::DwallResult<()> {
7 | setup_logging(&[env!("CARGO_PKG_NAME").replace("-", "_")]);
8 |
9 | let config = read_config_file().await?;
10 | apply_theme(config).await?;
11 |
12 | Ok(())
13 | }
14 |
--------------------------------------------------------------------------------
/daemon/src/monitor/error.rs:
--------------------------------------------------------------------------------
1 | use windows::{core::Error as WindowsError, Win32::Foundation::WIN32_ERROR};
2 |
3 | /// Monitor operation related errors
4 | ///
5 | /// Contains all possible errors that may occur during interaction with monitor devices
6 | #[derive(Debug, thiserror::Error)]
7 | pub enum MonitorError {
8 | /// Unable to get monitor device collection
9 | #[error("Unable to get monitor device collection: {0:?}")]
10 | GetDeviceInfoSet(#[source] Option),
11 |
12 | /// Unable to get monitor device information
13 | #[error("Unable to get monitor device information: {0:?}")]
14 | GetDeviceInfo(#[source] WindowsError),
15 |
16 | /// Unable to get target device name
17 | #[error("Unable to get target device name: {0:?}")]
18 | GetTargetName(#[source] WindowsError),
19 |
20 | /// Unable to find matching device
21 | #[error("Unable to find matching device")]
22 | MatchDevice,
23 |
24 | /// Unable to get device friendly name
25 | #[error("Unable to get device friendly name: {0:?}")]
26 | GetFriendlyName(#[source] WindowsError),
27 |
28 | /// Unable to get buffer sizes
29 | #[error("Unable to get buffer sizes: {0:?}")]
30 | GetBufferSizes(WIN32_ERROR),
31 |
32 | /// Failed to query display configuration
33 | #[error("Failed to query display configuration: {0:?}")]
34 | QueryDisplayConfig(WIN32_ERROR),
35 |
36 | /// Failed to get device registry property
37 | #[error("Failed to get device registry property: {0:?}")]
38 | GetDeviceRegistryProperty(#[source] WindowsError),
39 | }
40 |
--------------------------------------------------------------------------------
/daemon/src/position/geolocation_access.rs:
--------------------------------------------------------------------------------
1 | use windows::Devices::Geolocation::{GeolocationAccessStatus, Geolocator};
2 |
3 | use crate::error::{DwallError, DwallResult};
4 |
5 | use super::windows_api_helper::handle_windows_error;
6 |
7 | #[derive(Debug, thiserror::Error)]
8 | pub enum GeolocationAccessError {
9 | #[error("Geolocation permission was denied by the user")]
10 | Denied,
11 | #[error("Geolocation permission status is unspecified")]
12 | Unspecified,
13 | }
14 |
15 | /// Checks if the application has permission to access location
16 | ///
17 | /// Returns Ok(()) if permission is granted, or an error if denied or unspecified
18 | pub async fn check_location_permission() -> DwallResult<()> {
19 | let access_status = handle_windows_error("Requesting geolocation access permission", || {
20 | Geolocator::RequestAccessAsync()
21 | })
22 | .await?
23 | .get()
24 | .map_err(|e| {
25 | error!(error = ?e, "Failed to get access status");
26 | DwallError::Windows(e)
27 | })?;
28 |
29 | match access_status {
30 | GeolocationAccessStatus::Allowed => {
31 | debug!("Geolocation permission granted");
32 | Ok(())
33 | }
34 | GeolocationAccessStatus::Denied => {
35 | error!("{}", GeolocationAccessError::Denied);
36 | Err(GeolocationAccessError::Denied.into())
37 | }
38 | GeolocationAccessStatus::Unspecified => {
39 | error!("{}", GeolocationAccessError::Unspecified);
40 | Err(GeolocationAccessError::Unspecified.into())
41 | }
42 | _ => unreachable!(),
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/daemon/src/position/mod.rs:
--------------------------------------------------------------------------------
1 | //! Position module for handling geolocation functionality
2 | //!
3 | //! This module provides functionality for retrieving and managing geographical positions,
4 | //! including coordinate validation, geolocation access, and position caching.
5 |
6 | mod geolocation_access;
7 | mod position_manager;
8 | mod position_types;
9 | mod windows_api_helper;
10 |
11 | // Re-export public items
12 | pub use geolocation_access::{check_location_permission, GeolocationAccessError};
13 | pub(crate) use position_manager::PositionManager;
14 | pub use position_types::{Position, PositionError};
15 |
--------------------------------------------------------------------------------
/daemon/src/position/position_types.rs:
--------------------------------------------------------------------------------
1 | use std::fmt;
2 |
3 | use crate::error::DwallResult;
4 |
5 | #[derive(Debug, Clone)]
6 | pub struct Position {
7 | latitude: f64,
8 | longitude: f64,
9 | }
10 |
11 | impl Position {
12 | /// Creates a new Position with the given latitude and longitude
13 | ///
14 | /// # Arguments
15 | /// * `latitude` - The latitude in degrees, must be between -90 and 90
16 | /// * `longitude` - The longitude in degrees, must be between -180 and 180
17 | ///
18 | /// # Returns
19 | /// A new Position instance if the coordinates are valid, or PositionError if they are out of range
20 | pub fn new(latitude: f64, longitude: f64) -> DwallResult {
21 | if !Self::is_valid_latitude(latitude) {
22 | return Err(PositionError::InvalidLatitude(latitude).into());
23 | }
24 | if !Self::is_valid_longitude(longitude) {
25 | return Err(PositionError::InvalidLongitude(longitude).into());
26 | }
27 |
28 | Ok(Position {
29 | latitude,
30 | longitude,
31 | })
32 | }
33 |
34 | /// Creates a new Position without validation
35 | ///
36 | /// # Warning
37 | /// This method should only be used when the coordinates are already validated
38 | pub(crate) fn new_unchecked(latitude: f64, longitude: f64) -> Self {
39 | Position {
40 | latitude,
41 | longitude,
42 | }
43 | }
44 |
45 | /// Checks if the given latitude is valid (between -90 and 90 degrees)
46 | pub fn is_valid_latitude(latitude: f64) -> bool {
47 | (-90.0..=90.0).contains(&latitude)
48 | }
49 |
50 | /// Checks if the given longitude is valid (between -180 and 180 degrees)
51 | pub fn is_valid_longitude(longitude: f64) -> bool {
52 | (-180.0..=180.0).contains(&longitude)
53 | }
54 |
55 | pub fn latitude(&self) -> f64 {
56 | self.latitude
57 | }
58 |
59 | pub fn longitude(&self) -> f64 {
60 | self.longitude
61 | }
62 | }
63 |
64 | impl Default for Position {
65 | fn default() -> Self {
66 | // Default to coordinates (0, 0) - null island
67 | Self {
68 | latitude: 0.0,
69 | longitude: 0.0,
70 | }
71 | }
72 | }
73 |
74 | impl fmt::Display for Position {
75 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 | write!(
77 | f,
78 | "Position(lat: {}, lng: {})",
79 | self.latitude, self.longitude
80 | )
81 | }
82 | }
83 |
84 | /// Error type for position-related operations
85 | #[derive(Debug, thiserror::Error)]
86 | pub enum PositionError {
87 | #[error("Invalid latitude: {0}. Must be between -90 and 90 degrees")]
88 | InvalidLatitude(f64),
89 |
90 | #[error("Invalid longitude: {0}. Must be between -180 and 180 degrees")]
91 | InvalidLongitude(f64),
92 |
93 | #[error("Invalid coordinates: latitude {0}, longitude {1}")]
94 | InvalidCoordinates(f64, f64),
95 | }
96 |
97 | #[cfg(test)]
98 | mod tests {
99 | use super::*;
100 | use tokio::test;
101 |
102 | #[test]
103 | async fn test_position_new() {
104 | let pos = Position::new(45.0, 90.0).unwrap();
105 | assert_eq!(pos.latitude(), 45.0);
106 | assert_eq!(pos.longitude(), 90.0);
107 | }
108 |
109 | #[test]
110 | async fn test_position_new_invalid() {
111 | assert!(Position::new(91.0, 90.0).is_err());
112 | assert!(Position::new(45.0, 181.0).is_err());
113 | assert!(Position::new(-91.0, 0.0).is_err());
114 | assert!(Position::new(0.0, -181.0).is_err());
115 | }
116 |
117 | #[test]
118 | async fn test_position_validation() {
119 | assert!(Position::is_valid_latitude(90.0));
120 | assert!(Position::is_valid_latitude(-90.0));
121 | assert!(Position::is_valid_latitude(0.0));
122 | assert!(!Position::is_valid_latitude(90.1));
123 | assert!(!Position::is_valid_latitude(-90.1));
124 |
125 | assert!(Position::is_valid_longitude(180.0));
126 | assert!(Position::is_valid_longitude(-180.0));
127 | assert!(Position::is_valid_longitude(0.0));
128 | assert!(!Position::is_valid_longitude(180.1));
129 | assert!(!Position::is_valid_longitude(-180.1));
130 | }
131 |
132 | #[test]
133 | async fn test_position_default() {
134 | let pos = Position::default();
135 | assert_eq!(pos.latitude(), 0.0);
136 | assert_eq!(pos.longitude(), 0.0);
137 | }
138 |
139 | #[test]
140 | async fn test_position_display() {
141 | let pos = Position::new_unchecked(45.0, 90.0);
142 | assert_eq!(format!("{}", pos), "Position(lat: 45, lng: 90)");
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/daemon/src/position/windows_api_helper.rs:
--------------------------------------------------------------------------------
1 | //! Windows API helper module
2 |
3 | use crate::error::{DwallError, DwallResult};
4 |
5 | /// Helper function to handle Windows API errors with consistent logging
6 | ///
7 | /// This function maps a Windows error to a DwallError and logs the error message
8 | pub(super) async fn handle_windows_error(operation: &str, f: F) -> DwallResult
9 | where
10 | F: FnOnce() -> windows::core::Result,
11 | {
12 | trace!("{}", operation);
13 | match f() {
14 | Ok(result) => {
15 | debug!("{} completed successfully", operation);
16 | Ok(result)
17 | }
18 | Err(e) => {
19 | error!(error = ?e, "{} failed", operation);
20 | Err(DwallError::Windows(e))
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/daemon/src/theme/mod.rs:
--------------------------------------------------------------------------------
1 | use crate::{config::Config, error::DwallResult};
2 |
3 | use self::processor::ThemeProcessor;
4 | pub use self::validator::ThemeValidator;
5 |
6 | mod manager;
7 | mod processor;
8 | mod validator;
9 |
10 | /// Comprehensive error handling for theme-related operations
11 | #[derive(Debug, thiserror::Error)]
12 | pub enum ThemeError {
13 | #[error("Theme does not exist")]
14 | NotExists,
15 | #[error("Missing default theme")]
16 | MissingDefaultTheme,
17 | #[error("Missing solar configuration file")]
18 | MissingSolarConfigFile,
19 | #[error("Image count does not match solar configuration")]
20 | ImageCountMismatch,
21 | #[error("Wallpaper file does not exist")]
22 | MissingWallpaperFile,
23 | #[error("No monitor-specific wallpapers found")]
24 | NoMonitorSpecificWallpapers,
25 | }
26 |
27 | /// Applies a theme and starts a background task for periodic wallpaper updates
28 | pub async fn apply_theme(config: Config) -> DwallResult<()> {
29 | if config.monitor_specific_wallpapers().is_empty() {
30 | warn!("No monitor-specific wallpapers found, daemon will not be started");
31 | return Err(ThemeError::NoMonitorSpecificWallpapers.into());
32 | }
33 |
34 | let theme_processor = ThemeProcessor::new(&config)?;
35 |
36 | theme_processor.start_update_loop().await
37 | }
38 |
--------------------------------------------------------------------------------
/daemon/src/theme/validator.rs:
--------------------------------------------------------------------------------
1 | use std::path::Path;
2 |
3 | use tokio::fs;
4 |
5 | use crate::{error::DwallResult, solar::SolarAngle, theme::ThemeError};
6 |
7 | /// Theme validation utilities
8 | pub struct ThemeValidator;
9 |
10 | impl ThemeValidator {
11 | /// Checks if a theme exists and has valid configuration
12 | pub async fn validate_theme(themes_directory: &Path, theme_id: &str) -> DwallResult<()> {
13 | trace!(theme_id = theme_id, "Validating theme");
14 | let theme_dir = themes_directory.join(theme_id);
15 |
16 | if !theme_dir.exists() {
17 | warn!(theme_id = theme_id, "Theme directory not found");
18 | return Err(ThemeError::NotExists.into());
19 | }
20 |
21 | let solar_angles = Self::read_solar_configuration(&theme_dir).await?;
22 | let image_indices: Vec = solar_angles.iter().map(|angle| angle.index).collect();
23 |
24 | if !Self::validate_image_files(&theme_dir, &image_indices, "jpg") {
25 | warn!(theme_id = theme_id, "Image validation failed for theme");
26 | return Err(ThemeError::ImageCountMismatch.into());
27 | }
28 |
29 | debug!(theme_id = theme_id, "Theme validation successful");
30 | Ok(())
31 | }
32 |
33 | /// Reads solar configuration from theme directory
34 | async fn read_solar_configuration(theme_dir: &Path) -> DwallResult> {
35 | let solar_config_path = theme_dir.join("solar.json");
36 |
37 | if !solar_config_path.exists() {
38 | error!(solar_config_path = %solar_config_path.display(), "Solar configuration file missing");
39 | return Err(ThemeError::MissingSolarConfigFile.into());
40 | }
41 |
42 | let solar_config_content = fs::read_to_string(&solar_config_path).await.map_err(|e| {
43 | error!(error = ?e, "Failed to read solar configuration");
44 | e
45 | })?;
46 |
47 | let solar_angles: Vec =
48 | serde_json::from_str(&solar_config_content).map_err(|e| {
49 | error!(error = ?e, "Failed to parse solar configuration JSON");
50 | e
51 | })?;
52 |
53 | debug!(
54 | solar_angles_count = solar_angles.len(),
55 | "Loaded solar angles from configuration"
56 | );
57 | Ok(solar_angles)
58 | }
59 |
60 | /// Validates image files in the theme directory
61 | fn validate_image_files(theme_dir: &Path, indices: &[u8], image_format: &str) -> bool {
62 | let image_dir = theme_dir.join(image_format);
63 |
64 | if !image_dir.is_dir() {
65 | warn!(image_dir = %image_dir.display(), "Image directory not found");
66 | return false;
67 | }
68 |
69 | let validation_result = indices.iter().all(|&index| {
70 | let image_filename = format!("{}.{}", index + 1, image_format);
71 | let image_path = image_dir.join(image_filename);
72 |
73 | let is_valid = image_path.exists() && image_path.is_file();
74 | if !is_valid {
75 | warn!(image_path = %image_path.display(), "Missing or invalid image");
76 | }
77 | is_valid
78 | });
79 |
80 | validation_result
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/daemon/src/utils/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod string;
2 |
--------------------------------------------------------------------------------
/images/home.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/images/home.avif
--------------------------------------------------------------------------------
/images/settings.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/images/settings.avif
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | DWALL
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dwall-settings",
3 | "version": "0.1.24",
4 | "description": "",
5 | "type": "module",
6 | "scripts": {
7 | "build:vite": "vite build",
8 | "build:daemon": "cargo build -p dwall --release --features build-script,log-max-level-info",
9 | "build:daemon-dev": "cargo build -p dwall --features build-script,log-color",
10 | "build:daemon-debug": "cargo build -p dwall --release --features build-script",
11 | "build:daemon-stage": "cargo build -p dwall --release --features log-color,build-script",
12 | "build:debug": "tauri build --config src-tauri/tauri.debug.conf.json",
13 | "build": "tauri build --features log-max-level-info",
14 | "tauri": "tauri",
15 | "start": "vite",
16 | "start:dev": "cross-env RUST_BACKTRACE=1 tauri dev --features log-color",
17 | "dev": "bun run build:daemon-dev && bun run start:dev",
18 | "stage": "bun run build:daemon-stage && cross-env RUST_BACKTRACE=1 tauri dev --features log-color devtools --release",
19 | "serve": "vite preview",
20 | "check": "biome check --write src",
21 | "test": "vitest"
22 | },
23 | "license": "MIT",
24 | "dependencies": {
25 | "@tauri-apps/api": "^2.5.0",
26 | "@tauri-apps/plugin-dialog": "^2.2.2",
27 | "@tauri-apps/plugin-process": "^2.2.1",
28 | "@tauri-apps/plugin-shell": "^2.2.1",
29 | "@tauri-apps/plugin-updater": "^2.7.1",
30 | "@vanilla-extract/css": "^1.17.2",
31 | "fluent-solid": "^0.2.1",
32 | "solid-icons": "^1.1.0",
33 | "solid-js": "^1.9.7"
34 | },
35 | "devDependencies": {
36 | "@biomejs/biome": "^1.9.4",
37 | "@tauri-apps/cli": "^2.5.0",
38 | "@types/node": "^22.15.21",
39 | "@vanilla-extract/vite-plugin": "^5.0.2",
40 | "cross-env": "^7.0.3",
41 | "jsdom": "^26.1.0",
42 | "sass": "^1.89.0",
43 | "typescript": "^5.8.3",
44 | "vite": "^6.3.5",
45 | "vite-plugin-solid": "^2.11.6",
46 | "vitest": "^3.1.4"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src-tauri/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | /target/
4 |
5 | # Generated by Tauri
6 | # will have schema files for capabilities auto-completion
7 | /gen/schemas
8 |
--------------------------------------------------------------------------------
/src-tauri/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "dwall-settings"
3 | description = "Dwall settings"
4 | edition.workspace = true
5 | version.workspace = true
6 | authors.workspace = true
7 | repository.workspace = true
8 | license.workspace = true
9 |
10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
11 |
12 | [build-dependencies]
13 | tauri-build = { version = "2", features = [] }
14 |
15 | [dependencies]
16 | tauri = { version = "2", features = ["protocol-asset"] }
17 | dwall = { version = "0", path = "../daemon" }
18 |
19 | reqwest = { version = "0", default-features = false }
20 | futures-util = { version = "0", default-features = false }
21 | zip-extract = { version = "0", default-features = false, features = [
22 | "deflate",
23 | ] }
24 | open = { version = "5", default-features = false }
25 | tokio = { workspace = true, features = ["macros", "process"] }
26 | dirs = { workspace = true }
27 | serde = { workspace = true }
28 | serde_json = { workspace = true }
29 | thiserror = { workspace = true }
30 | tracing = { workspace = true }
31 | tracing-subscriber = { workspace = true }
32 | windows = { workspace = true, features = [
33 | "Win32_System_Registry",
34 | "Win32_System_Diagnostics_ToolHelp",
35 | "Win32_System_ProcessStatus",
36 | "Win32_Graphics_Dwm",
37 | "Wdk_System_SystemServices",
38 | "Win32_System_SystemInformation",
39 | "Win32_Globalization",
40 | "Win32_System_Threading",
41 | ] }
42 | rand = { version = "0.9", default-features = false, features = ["thread_rng"] }
43 |
44 | tauri-plugin-shell = "2"
45 | tauri-plugin-process = "2"
46 |
47 | [target.'cfg(target_os = "windows")'.dependencies]
48 | tauri-plugin-single-instance = "2"
49 | tauri-plugin-updater = "2"
50 | tauri-plugin-dialog = "2"
51 |
52 | [features]
53 | default = []
54 | log-color = ["dwall/log-color"]
55 | devtools = ["tauri/devtools"]
56 | log-max-level-info = ["tracing/release_max_level_info"]
57 |
--------------------------------------------------------------------------------
/src-tauri/build.rs:
--------------------------------------------------------------------------------
1 | fn main() {
2 | tauri_build::build()
3 | }
4 |
--------------------------------------------------------------------------------
/src-tauri/capabilities/default.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "../gen/schemas/desktop-schema.json",
3 | "identifier": "default",
4 | "description": "Capability for the main window",
5 | "windows": ["main"],
6 | "permissions": [
7 | "core:default",
8 | "updater:default",
9 | "dialog:default",
10 | "process:default",
11 | "shell:default"
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/src-tauri/icons/128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/128x128.png
--------------------------------------------------------------------------------
/src-tauri/icons/128x128@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/128x128@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/32x32.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square107x107Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/Square107x107Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square142x142Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/Square142x142Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square150x150Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/Square150x150Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square284x284Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/Square284x284Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square30x30Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/Square30x30Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square310x310Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/Square310x310Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square44x44Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/Square44x44Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square71x71Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/Square71x71Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square89x89Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/Square89x89Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/StoreLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/StoreLogo.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/src-tauri/icons/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/icon.icns
--------------------------------------------------------------------------------
/src-tauri/icons/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/icon.ico
--------------------------------------------------------------------------------
/src-tauri/icons/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/icon.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-20x20@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-20x20@1x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-20x20@2x-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-20x20@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-20x20@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-20x20@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-20x20@3x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-29x29@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-29x29@1x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-29x29@2x-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-29x29@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-29x29@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-29x29@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-29x29@3x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-40x40@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-40x40@1x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-40x40@2x-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-40x40@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-40x40@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-40x40@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-40x40@3x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-512@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-512@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-60x60@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-60x60@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-60x60@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-60x60@3x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-76x76@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-76x76@1x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-76x76@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-76x76@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwall-rs/dwall/947d9e0e3a93b831b3c1b9c2354a652575c59b0e/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
--------------------------------------------------------------------------------
/src-tauri/src/cache/error.rs:
--------------------------------------------------------------------------------
1 | //! Error types for the cache module
2 |
3 | use std::path::PathBuf;
4 | use thiserror::Error;
5 |
6 | use crate::error::DwallSettingsError;
7 |
8 | /// Errors that can occur during cache operations
9 | #[derive(Debug, Error)]
10 | pub enum CacheError {
11 | /// Error occurred during HTTP request
12 | #[error(transparent)]
13 | Request(#[from] reqwest::Error),
14 |
15 | /// Error occurred during file system operations
16 | #[error(transparent)]
17 | Io(#[from] std::io::Error),
18 |
19 | /// Error occurred when downloading image
20 | #[error("Failed to download image after {retries} retries")]
21 | DownloadFailed {
22 | /// URL that failed to download
23 | url: String,
24 | /// Number of retries attempted
25 | retries: u32,
26 | },
27 |
28 | /// Error occurred when cache file expected but not found
29 | #[error("Cached file not found: {0}")]
30 | CachedFileNotFound(PathBuf),
31 |
32 | /// Other unspecified errors
33 | #[error("{0}")]
34 | Other(String),
35 | }
36 |
37 | /// Result type for cache operations
38 | pub type CacheResult = std::result::Result;
39 |
40 | impl From for DwallSettingsError {
41 | fn from(value: CacheError) -> Self {
42 | match value {
43 | CacheError::Request(error) => DwallSettingsError::Request(error),
44 | CacheError::Io(error) => DwallSettingsError::Io(error),
45 | _ => DwallSettingsError::Other(value.to_string()),
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src-tauri/src/cache/fs_service.rs:
--------------------------------------------------------------------------------
1 | //! File system service for cache operations
2 | //!
3 | //! This module provides functionality for file system operations related to caching.
4 |
5 | use std::fs::File;
6 | use std::path::Path;
7 |
8 | use tokio::fs;
9 |
10 | use crate::fs::create_dir_if_missing;
11 |
12 | use super::error::CacheResult;
13 |
14 | /// Service for file system operations related to caching
15 | pub struct FsService;
16 |
17 | impl FsService {
18 | /// Ensure that the required directories exist
19 | pub async fn ensure_directories(theme_dir: &Path) -> CacheResult<()> {
20 | create_dir_if_missing(theme_dir).await.map_err(Into::into)
21 | }
22 |
23 | /// Update file access time to prevent premature expiration
24 | pub fn update_file_access_time(path: &Path) -> std::io::Result<()> {
25 | // Simply open and close the file to update access time
26 | File::open(path)?;
27 | Ok(())
28 | }
29 |
30 | /// Check if a directory is empty
31 | pub async fn is_directory_empty(dir: &Path) -> bool {
32 | match fs::read_dir(dir).await {
33 | Ok(mut entries) => matches!(entries.next_entry().await, Ok(None)),
34 | Err(_) => false,
35 | }
36 | }
37 |
38 | /// Get the size of a directory recursively
39 | pub async fn get_directory_size(dir: &Path) -> CacheResult {
40 | let mut total_size = 0;
41 | let mut entries = fs::read_dir(dir).await.map_err(|e| {
42 | error!(
43 | dir = %dir.display(),
44 | error = ?e,
45 | "Failed to read directory"
46 | );
47 | e
48 | })?;
49 |
50 | while let Ok(Some(entry)) = entries.next_entry().await {
51 | let path = entry.path();
52 |
53 | if path.is_file() {
54 | if let Ok(metadata) = fs::metadata(&path).await {
55 | total_size += metadata.len();
56 | }
57 | } else if path.is_dir() {
58 | total_size += Box::pin(Self::get_directory_size(&path)).await?;
59 | }
60 | }
61 |
62 | Ok(total_size)
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src-tauri/src/cache/mod.rs:
--------------------------------------------------------------------------------
1 | //! Thumbnail cache functionality
2 | //!
3 | //! This module provides functionality for caching and managing thumbnail images.
4 |
5 | mod cache_manager;
6 | mod cleanup_service;
7 | mod error;
8 | mod fs_service;
9 | mod http_service;
10 |
11 | // Re-export the public API
12 | pub use self::cache_manager::{clear_thumbnail_cache, get_or_save_cached_thumbnails};
13 |
--------------------------------------------------------------------------------
/src-tauri/src/download/downloader.rs:
--------------------------------------------------------------------------------
1 | //! Theme download coordination
2 | //!
3 | //! This module coordinates the theme download process using various components.
4 |
5 | use std::path::PathBuf;
6 |
7 | use dwall::config::Config;
8 | use tauri::{Runtime, State, WebviewWindow};
9 |
10 | use crate::error::DwallSettingsResult;
11 |
12 | use super::extractor::ThemeExtractor;
13 | use super::file_manager::ThemeFileManager;
14 | use super::http_service::HttpDownloadService;
15 | use super::task_manager::{DownloadTaskManager, ProgressEmitter};
16 |
17 | /// Coordinates the theme download process
18 | pub struct ThemeDownloader {
19 | download_service: HttpDownloadService,
20 | task_manager: DownloadTaskManager,
21 | }
22 |
23 | impl ThemeDownloader {
24 | /// Create a new theme downloader
25 | pub fn new() -> Self {
26 | Self {
27 | download_service: HttpDownloadService::new(),
28 | task_manager: DownloadTaskManager::new(),
29 | }
30 | }
31 |
32 | /// Download theme zip file
33 | async fn download_theme(
34 | &self,
35 | config: &Config,
36 | theme_id: &str,
37 | progress_emitter: Option<&ProgressEmitter<'_, R>>,
38 | ) -> DwallSettingsResult {
39 | // Add download task and get the cancel flag
40 | let cancel_flag = self.task_manager.add_task(theme_id).await?;
41 |
42 | // Get file paths
43 | let (target_dir, temp_theme_zip_file, theme_zip_file) =
44 | ThemeFileManager::build_theme_paths(config, theme_id);
45 |
46 | // Prepare target directories
47 | ThemeFileManager::prepare_theme_directory(&target_dir).await?;
48 |
49 | // Construct download URL
50 | let github_url = HttpDownloadService::build_download_url(theme_id);
51 | let asset_url = config.github_asset_url(&github_url);
52 |
53 | // Download the file
54 | let download_result = self
55 | .download_service
56 | .download_file(
57 | &asset_url,
58 | &temp_theme_zip_file,
59 | theme_id,
60 | cancel_flag.clone(),
61 | progress_emitter,
62 | &self.task_manager,
63 | )
64 | .await;
65 |
66 | // Handle download result
67 | match download_result {
68 | Ok(_) => {
69 | // Finalize the download
70 | ThemeFileManager::finalize_download(&temp_theme_zip_file, &theme_zip_file).await?;
71 | // Remove download task from tracking
72 | self.task_manager.remove_task(theme_id).await;
73 | Ok(theme_zip_file)
74 | }
75 | Err(e) => {
76 | // Only clean up temporary file if it's a non-resumable error or empty file
77 | if let Ok(metadata) = tokio::fs::metadata(&temp_theme_zip_file).await {
78 | if metadata.len() == 0 {
79 | // Clean up empty temporary file
80 | ThemeFileManager::cleanup_temp_file(&temp_theme_zip_file, false).await;
81 | } else {
82 | // Keep the partial download for future resume
83 | debug!(
84 | theme_id = theme_id,
85 | "Keeping partial download for future resume"
86 | );
87 | }
88 | }
89 | // Remove download task from tracking
90 | self.task_manager.remove_task(theme_id).await;
91 | Err(e)
92 | }
93 | }
94 | }
95 |
96 | async fn cancel_theme_download(&self, theme_id: &str) {
97 | self.task_manager.cancel_task(theme_id).await;
98 | }
99 | }
100 |
101 | /// Download and extract a theme
102 | #[tauri::command]
103 | pub async fn download_theme_and_extract(
104 | window: WebviewWindow,
105 | downloader: State<'_, ThemeDownloader>,
106 | config: Config,
107 | theme_id: &str,
108 | ) -> DwallSettingsResult<()> {
109 | let progress_emitter = ProgressEmitter::new(&window);
110 |
111 | // Download theme
112 | let zip_path = downloader
113 | .download_theme(&config, theme_id, Some(&progress_emitter))
114 | .await?;
115 |
116 | // Extract theme
117 | ThemeExtractor::extract_theme(config.themes_directory(), &zip_path, theme_id).await
118 | }
119 |
120 | /// Cancel an ongoing theme download
121 | #[tauri::command]
122 | pub async fn cancel_theme_download(
123 | downloader: State<'_, ThemeDownloader>,
124 | theme_id: String,
125 | ) -> DwallSettingsResult<()> {
126 | downloader.cancel_theme_download(&theme_id).await;
127 | Ok(())
128 | }
129 |
--------------------------------------------------------------------------------
/src-tauri/src/download/error.rs:
--------------------------------------------------------------------------------
1 | //! Error types for the download module
2 |
3 | use std::error::Error;
4 |
5 | #[derive(Debug, thiserror::Error)]
6 | pub enum DownloadError {
7 | #[error("{0}")]
8 | Connect(String),
9 | #[error("Download cancelled")]
10 | Cancelled,
11 | #[error("The theme does not exist on the server: {0}")]
12 | NotFound(String),
13 | #[error("Unhandled Error: {0}")]
14 | Unknown(String),
15 | }
16 |
17 | impl From for DownloadError {
18 | fn from(value: reqwest::Error) -> Self {
19 | let source = value
20 | .source()
21 | .map(|e| format!("{:?}", e))
22 | .unwrap_or("".to_string());
23 |
24 | if value.is_connect() {
25 | return Self::Connect(source[43..source.len() - 1].to_string());
26 | }
27 |
28 | Self::Unknown(source)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src-tauri/src/download/extractor.rs:
--------------------------------------------------------------------------------
1 | //! Theme extraction functionality
2 | //!
3 | //! This module provides functionality for extracting downloaded theme archives.
4 |
5 | use std::path::Path;
6 |
7 | use tokio::fs;
8 |
9 | use crate::error::DwallSettingsResult;
10 |
11 | /// Theme extraction service
12 | pub struct ThemeExtractor;
13 |
14 | impl ThemeExtractor {
15 | /// Extract downloaded theme
16 | pub async fn extract_theme(
17 | themes_directory: &Path,
18 | zip_path: &Path,
19 | theme_id: &str,
20 | ) -> DwallSettingsResult<()> {
21 | let target_dir = themes_directory.join(theme_id);
22 |
23 | // Read downloaded file
24 | let archive = fs::read(zip_path).await.map_err(|e| {
25 | error!(
26 | theme_id = theme_id,
27 | zip_path = %zip_path.display(),
28 | error = ?e,
29 | "Failed to read theme archive"
30 | );
31 | e
32 | })?;
33 |
34 | // Extract theme
35 | zip_extract::extract(std::io::Cursor::new(archive), &target_dir, true).map_err(|e| {
36 | error!(
37 | theme_id = theme_id,
38 | target_dir = %target_dir.display(),
39 | zip_path = %zip_path.display(),
40 | error = ?e,
41 | "Failed to extract theme archive"
42 | );
43 | e
44 | })?;
45 |
46 | info!(
47 | theme_id = theme_id,
48 | target_dir = %target_dir.display(),
49 | "Successfully extracted theme"
50 | );
51 |
52 | // Clean up zip file
53 | fs::remove_file(zip_path).await.map_err(|e| {
54 | error!(
55 | theme_id = theme_id,
56 | zip_path = %zip_path.display(),
57 | error = ?e,
58 | "Failed to delete theme archive"
59 | );
60 | e
61 | })?;
62 |
63 | info!(
64 | theme_id = theme_id,
65 | zip_path = %zip_path.display(),
66 | "Deleted theme archive"
67 | );
68 | Ok(())
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src-tauri/src/download/file_manager.rs:
--------------------------------------------------------------------------------
1 | //! Theme file management
2 | //!
3 | //! This module provides functionality for managing theme files and directories.
4 |
5 | use std::path::{Path, PathBuf};
6 |
7 | use dwall::config::Config;
8 | use tokio::fs;
9 |
10 | use crate::error::DwallSettingsResult;
11 |
12 | /// Handles file system operations for theme management
13 | pub(super) struct ThemeFileManager;
14 |
15 | impl ThemeFileManager {
16 | /// Build paths for theme files
17 | pub(super) fn build_theme_paths(
18 | config: &Config,
19 | theme_id: &str,
20 | ) -> (PathBuf, PathBuf, PathBuf) {
21 | let target_dir = config.themes_directory().join(theme_id);
22 | let temp_theme_zip_file = config
23 | .themes_directory()
24 | .join(format!("{}.zip.temp", theme_id));
25 | let theme_zip_file = config.themes_directory().join(format!("{}.zip", theme_id));
26 |
27 | (target_dir, temp_theme_zip_file, theme_zip_file)
28 | }
29 |
30 | /// Prepare theme directory for download
31 | pub(super) async fn prepare_theme_directory(target_dir: &Path) -> DwallSettingsResult<()> {
32 | // Remove existing directory if it exists
33 | if target_dir.exists() {
34 | fs::remove_dir_all(target_dir).await.map_err(|e| {
35 | error!(
36 | dir_path = %target_dir.display(),
37 | error = ?e,
38 | "Failed to remove existing theme directory"
39 | );
40 | e
41 | })?;
42 | trace!("Removed existing theme directory");
43 | }
44 |
45 | // Create new directory
46 | fs::create_dir_all(target_dir).await.map_err(|e| {
47 | error!(
48 | dir_path = %target_dir.display(),
49 | error = ?e,
50 | "Failed to create theme directory"
51 | );
52 | e
53 | })?;
54 |
55 | trace!(dir_path = %target_dir.display(), "Created new theme directory");
56 | Ok(())
57 | }
58 |
59 | /// Clean up temporary file if download is cancelled or failed
60 | ///
61 | /// If force_cleanup is false, the file will only be removed if it's empty
62 | pub async fn cleanup_temp_file(temp_file_path: &Path, force_cleanup: bool) {
63 | if temp_file_path.exists() {
64 | // Check if we should keep the file for resuming download
65 | if !force_cleanup {
66 | if let Ok(metadata) = fs::metadata(temp_file_path).await {
67 | if metadata.len() > 0 {
68 | debug!(file_path = %temp_file_path.display(), size = metadata.len(), "Keeping temporary file for resume");
69 | return;
70 | }
71 | }
72 | }
73 |
74 | // Remove the file
75 | if let Err(e) = fs::remove_file(temp_file_path).await {
76 | error!(
77 | file_path = %temp_file_path.display(),
78 | error = ?e,
79 | "Failed to remove temporary file"
80 | );
81 | } else {
82 | debug!(file_path = %temp_file_path.display(), "Removed temporary download file");
83 | }
84 | }
85 | }
86 |
87 | /// Rename temporary file to final file
88 | pub(super) async fn finalize_download(
89 | temp_file_path: &Path,
90 | final_file_path: &Path,
91 | ) -> DwallSettingsResult<()> {
92 | fs::rename(temp_file_path, final_file_path)
93 | .await
94 | .map_err(|e| {
95 | error!(
96 | from = %temp_file_path.display(),
97 | to = %final_file_path.display(),
98 | error = ?e,
99 | "Failed to rename temporary file to final file"
100 | );
101 | e.into()
102 | })
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src-tauri/src/download/mod.rs:
--------------------------------------------------------------------------------
1 | //! Theme download functionality
2 | //!
3 | //! This module provides functionality for downloading and extracting themes.
4 |
5 | mod downloader;
6 | mod error;
7 | mod extractor;
8 | mod file_manager;
9 | mod http_service;
10 | mod task_manager;
11 |
12 | pub use downloader::ThemeDownloader;
13 | pub use error::DownloadError;
14 |
15 | // Re-export the public API
16 | pub use self::downloader::{cancel_theme_download, download_theme_and_extract};
17 |
--------------------------------------------------------------------------------
/src-tauri/src/download/task_manager.rs:
--------------------------------------------------------------------------------
1 | //! Download task management
2 | //!
3 | //! This module provides functionality for managing download tasks and tracking progress.
4 |
5 | use std::collections::HashMap;
6 | use std::sync::atomic::{AtomicBool, Ordering};
7 | use std::sync::{Arc, LazyLock};
8 |
9 | use serde::Serialize;
10 | use tauri::{Emitter, Runtime, WebviewWindow};
11 | use tokio::sync::Mutex;
12 |
13 | use crate::error::DwallSettingsResult;
14 |
15 | use super::error::DownloadError;
16 |
17 | /// Download task information
18 | #[derive(Debug)]
19 | pub(super) struct DownloadTask {
20 | /// Flag to indicate if the download should be cancelled
21 | pub cancel: Arc,
22 | }
23 |
24 | /// Download progress tracking
25 | #[derive(Serialize, Clone, Debug)]
26 | pub(super) struct DownloadProgress<'a> {
27 | pub theme_id: &'a str,
28 | pub downloaded_bytes: u64,
29 | pub total_bytes: u64,
30 | }
31 |
32 | /// Progress notification service
33 | pub(super) struct ProgressEmitter<'a, R: Runtime> {
34 | window: &'a WebviewWindow,
35 | }
36 |
37 | impl<'a, R: Runtime> ProgressEmitter<'a, R> {
38 | pub fn new(window: &'a WebviewWindow) -> Self {
39 | Self { window }
40 | }
41 |
42 | pub fn emit_progress(&self, progress: DownloadProgress) -> Result<(), tauri::Error> {
43 | self.window.emit("download-theme", progress)
44 | }
45 | }
46 |
47 | /// Manages download tasks and their cancellation flags
48 | pub(super) struct DownloadTaskManager {
49 | download_tasks: LazyLock>>>,
50 | }
51 |
52 | impl DownloadTaskManager {
53 | /// Create a new download task manager
54 | pub(super) fn new() -> Self {
55 | Self {
56 | download_tasks: LazyLock::new(|| Arc::new(Mutex::new(HashMap::new()))),
57 | }
58 | }
59 |
60 | /// Add a new download task and return its cancellation flag
61 | pub(super) async fn add_task(&self, theme_id: &str) -> DwallSettingsResult> {
62 | let mut tasks = self.download_tasks.lock().await;
63 | if tasks.contains_key(theme_id) {
64 | error!(theme_id = theme_id, "Theme is already being downloaded");
65 | return Err(
66 | DownloadError::Unknown("Theme is already being downloaded".to_string()).into(),
67 | );
68 | }
69 |
70 | // Mark theme as being downloaded with cancel flag
71 | let cancel_flag = Arc::new(AtomicBool::new(false));
72 | tasks.insert(
73 | theme_id.to_string(),
74 | DownloadTask {
75 | cancel: cancel_flag.clone(),
76 | },
77 | );
78 | drop(tasks);
79 |
80 | Ok(cancel_flag)
81 | }
82 |
83 | /// Remove a download task
84 | pub(super) async fn remove_task(&self, theme_id: &str) {
85 | let mut tasks = self.download_tasks.lock().await;
86 | tasks.remove(theme_id);
87 | }
88 |
89 | /// Check if a task should be cancelled
90 | pub(super) fn is_cancelled(&self, cancel_flag: &Arc) -> bool {
91 | cancel_flag.load(Ordering::Relaxed)
92 | }
93 |
94 | /// Cancel a download task
95 | pub(super) async fn cancel_task(&self, theme_id: &str) {
96 | let tasks = self.download_tasks.lock().await;
97 |
98 | if let Some(task) = tasks.get(theme_id) {
99 | // Set the cancel flag to true
100 | task.cancel.store(true, Ordering::Relaxed);
101 | info!(
102 | theme_id = theme_id,
103 | "Requested cancellation of theme download"
104 | );
105 | } else {
106 | // Theme is not being downloaded
107 | warn!(
108 | theme_id = theme_id,
109 | "Attempted to cancel download for theme that is not being downloaded"
110 | );
111 | }
112 |
113 | drop(tasks);
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src-tauri/src/error.rs:
--------------------------------------------------------------------------------
1 | use dwall::registry::RegistryError;
2 | use serde::{Serialize, Serializer};
3 |
4 | use crate::download::DownloadError;
5 |
6 | pub type DwallSettingsResult = std::result::Result;
7 |
8 | #[derive(Debug, thiserror::Error)]
9 | pub enum DwallSettingsError {
10 | #[error(transparent)]
11 | Tauri(#[from] tauri::Error),
12 | #[error(transparent)]
13 | Update(#[from] tauri_plugin_updater::Error),
14 | #[error(transparent)]
15 | Request(#[from] reqwest::Error),
16 | #[error(transparent)]
17 | ZipExtract(#[from] zip_extract::ZipExtractError),
18 | #[error(transparent)]
19 | Dwall(#[from] dwall::DwallError),
20 | #[error(transparent)]
21 | Io(#[from] std::io::Error),
22 | #[error(transparent)]
23 | Windows(#[from] windows::core::Error),
24 | #[error(transparent)]
25 | Registry(#[from] RegistryError),
26 | #[error(transparent)]
27 | NulError(#[from] std::ffi::NulError),
28 | #[error(transparent)]
29 | Download(#[from] DownloadError),
30 | #[error("Failed to spawn daemon: {0}")]
31 | Daemon(String),
32 | #[error("{0}")]
33 | Other(String),
34 | }
35 |
36 | impl Serialize for DwallSettingsError {
37 | fn serialize(&self, serializer: S) -> std::result::Result
38 | where
39 | S: Serializer,
40 | {
41 | serializer.serialize_str(self.to_string().as_ref())
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src-tauri/src/i18n/locales/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod en_us;
2 | pub mod ja_jp;
3 | pub mod zh_cn;
4 | pub mod zh_hk;
5 | pub mod zh_tw;
6 |
--------------------------------------------------------------------------------
/src-tauri/src/i18n/utils.rs:
--------------------------------------------------------------------------------
1 | use windows::{core::PWSTR, Win32::Globalization::GetUserPreferredUILanguages};
2 |
3 | use crate::error::DwallSettingsResult;
4 |
5 | pub(super) fn get_user_preferred_language() -> DwallSettingsResult {
6 | trace!("Entering get_user_preferred_language function");
7 |
8 | let mut num_languages: u32 = 0;
9 | let mut buffer_size: u32 = 0;
10 |
11 | unsafe {
12 | GetUserPreferredUILanguages(
13 | 0, // dwflags, 0 means no special options
14 | &mut num_languages, // Number of languages (output)
15 | None, // No buffer yet, to get the size first
16 | &mut buffer_size,
17 | )?
18 | };
19 |
20 | debug!(buffer_size = buffer_size, "Initial buffer size obtained");
21 | let mut buffer: Vec = vec![0; buffer_size as usize];
22 | unsafe {
23 | GetUserPreferredUILanguages(
24 | 0, // dwflags, 0 means no special options
25 | &mut num_languages, // Number of languages (output)
26 | Some(PWSTR(buffer.as_mut_ptr())), // The buffer to hold the languages
27 | &mut buffer_size, // Buffer size (output)
28 | )?;
29 | }
30 |
31 | trace!(
32 | num_languages = num_languages,
33 | "Number of user preferred languages obtained"
34 | );
35 |
36 | let languages = buffer
37 | .split(|&c| c == 0)
38 | .filter_map(|chunk| String::from_utf16(chunk).ok())
39 | .collect::>();
40 |
41 | if languages.is_empty() {
42 | warn!("No user preferred languages found, falling back to en-US");
43 | return Ok("en-US".to_string());
44 | }
45 |
46 | let language = &languages[0];
47 |
48 | debug!(
49 | language = language,
50 | "Successfully got user preferred language"
51 | );
52 | Ok(language.to_string())
53 | }
54 |
--------------------------------------------------------------------------------
/src-tauri/src/monitor.rs:
--------------------------------------------------------------------------------
1 | use std::collections::HashMap;
2 |
3 | use dwall::{
4 | monitor::{Monitor, MonitorInfoProvider},
5 | DwallResult,
6 | };
7 |
8 | pub async fn get_monitors() -> DwallResult> {
9 | MonitorInfoProvider::new().get_monitors().await
10 | }
11 |
--------------------------------------------------------------------------------
/src-tauri/src/postion.rs:
--------------------------------------------------------------------------------
1 | use crate::error::DwallSettingsResult;
2 |
3 | #[tauri::command]
4 | pub async fn request_location_permission() -> DwallSettingsResult<()> {
5 | dwall::position::check_location_permission()
6 | .await
7 | .map_err(Into::into)
8 | }
9 |
--------------------------------------------------------------------------------
/src-tauri/src/setup.rs:
--------------------------------------------------------------------------------
1 | use std::{env, path::PathBuf, str::FromStr};
2 |
3 | use tauri::Manager;
4 |
5 | use crate::{
6 | download::ThemeDownloader, error::DwallSettingsError, process_manager::find_daemon_process,
7 | read_config_file, theme::spawn_apply_daemon, window::create_main_window, DAEMON_EXE_PATH,
8 | };
9 |
10 | pub fn setup_app(app: &mut tauri::App) -> Result<(), Box> {
11 | info!(
12 | version = app.package_info().version.to_string(),
13 | build_mode = if cfg!(debug_assertions) {
14 | "debug"
15 | } else {
16 | "release"
17 | },
18 | "Starting application"
19 | );
20 |
21 | //#[cfg(all(desktop, not(debug_assertions)))]
22 | setup_updater(app)?;
23 |
24 | // Process launch arguments
25 | let args: Vec = env::args().collect();
26 | debug!(arguments = ?args, "Launch arguments");
27 |
28 | let settings_exe_path = PathBuf::from_str(&args[0])?;
29 | let daemon_exe_path = settings_exe_path
30 | .parent()
31 | .ok_or(DwallSettingsError::Io(std::io::ErrorKind::NotFound.into()))?
32 | .join("dwall.exe");
33 | if !daemon_exe_path.exists() || !daemon_exe_path.is_file() {
34 | error!("Daemon executable does not exist");
35 | return Err(Box::new(std::io::Error::from(std::io::ErrorKind::NotFound)));
36 | }
37 | info!(path = %daemon_exe_path.display(), "Found daemon exe");
38 | DAEMON_EXE_PATH.set(daemon_exe_path)?;
39 |
40 | let theme_downloader = ThemeDownloader::new();
41 | app.manage(theme_downloader);
42 |
43 | create_main_window(app.app_handle())?;
44 |
45 | // If a theme is configured in the configuration file but the background process is not detected,
46 | // then run the background process when this program starts.
47 | tauri::async_runtime::spawn(async move {
48 | let _ = read_config_file()
49 | .await
50 | .and_then(|_| find_daemon_process())
51 | .and_then(|pid| pid.map_or_else(|| spawn_apply_daemon().map(|_| ()), |_| Ok(())));
52 | });
53 |
54 | info!("Application setup completed successfully");
55 |
56 | Ok(())
57 | }
58 |
59 | //#[cfg(all(desktop, not(debug_assertions)))]
60 | fn setup_updater(app: &mut tauri::App) -> Result<(), Box> {
61 | debug!("Initializing update plugin");
62 |
63 | // Initialize update plugin
64 | app.handle()
65 | .plugin(tauri_plugin_updater::Builder::new().build())
66 | .map_err(|e| {
67 | error!(error = ?e, "Failed to initialize update plugin");
68 | e
69 | })?;
70 |
71 | Ok(())
72 | }
73 |
--------------------------------------------------------------------------------
/src-tauri/tauri.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "productName": "Dwall Settings",
3 | "version": "../package.json",
4 | "identifier": "com.thep0y.dwall",
5 | "build": {
6 | "beforeDevCommand": "bun run start",
7 | "devUrl": "http://localhost:1420",
8 | "beforeBuildCommand": "bun run build:vite && bun run build:daemon",
9 | "frontendDist": "../dist"
10 | },
11 | "app": {
12 | "security": {
13 | "csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost",
14 | "assetProtocol": {
15 | "enable": true,
16 | "scope": ["$CACHE/com.thep0y.dwall/**"]
17 | }
18 | }
19 | },
20 | "bundle": {
21 | "resources": { "../target/release/dwall.exe": "dwall.exe" },
22 | "active": true,
23 | "copyright": "Copyright (C) 2025 thep0y. All rights reserved.",
24 | "createUpdaterArtifacts": true,
25 | "targets": "nsis",
26 | "icon": [
27 | "icons/32x32.png",
28 | "icons/128x128.png",
29 | "icons/128x128@2x.png",
30 | "icons/icon.icns",
31 | "icons/icon.ico"
32 | ],
33 | "windows": {
34 | "nsis": {
35 | "languages": ["SimpChinese", "English"],
36 | "displayLanguageSelector": true,
37 | "installerIcon": "icons/icon.ico",
38 | "installerHooks": "./windows/hooks.nsi"
39 | }
40 | }
41 | },
42 | "plugins": {
43 | "updater": {
44 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDcxQTQ5NDZBNUIyMEVDRTUKUldUbDdDQmJhcFNrY2RYSkpGNUt0U3cvdEozMXJoN2pXeEFBcUQ4YmZMTi9MS2E2YjNQT1pSbTgK",
45 | "endpoints": [
46 | "https://app.thepoy.cc/dwall/latest-mirror-1.json",
47 | "https://app.thepoy.cc/dwall/latest-mirror-2.json",
48 | "https://gh-proxy.com/https://github.com/dwall-rs/dwall/releases/latest/download/latest.json",
49 | "https://github.com/dwall-rs/dwall/releases/latest/download/latest.json"
50 | ]
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src-tauri/tauri.debug.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "build": {
3 | "beforeBuildCommand": "bun run build:vite && bun run build:daemon-debug"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src-tauri/windows/hooks.nsi:
--------------------------------------------------------------------------------
1 | !include "StrFunc.nsh"
2 |
3 | !macro TerminateDwallProcess
4 | nsis_tauri_utils::FindProcess "dwall.exe"
5 | Pop $R0
6 | ${If} $R0 = 0
7 | DetailPrint "Found dwall.exe process running, attempting to terminate..."
8 | nsis_tauri_utils::KillProcess "dwall.exe"
9 | Pop $R0
10 |
11 | ${If} $R0 = 0
12 | DetailPrint "Successfully terminated dwall.exe process"
13 | ${Else}
14 | DetailPrint "Failed to terminate process using nsis_tauri_utils, trying taskkill..."
15 | # If above method failed, try using taskkill
16 | nsExec::ExecToStack `taskkill /F /IM dwall.exe`
17 | Pop $R1
18 | Pop $R2
19 | ${If} $R1 = 0
20 | DetailPrint "Successfully terminated dwall.exe process using taskkill"
21 | ${Else}
22 | DetailPrint "Failed to terminate dwall.exe process: $R2"
23 | ${EndIf}
24 | ${EndIf}
25 |
26 | # Add delay to ensure process is fully terminated
27 | DetailPrint "Waiting for process to fully terminate..."
28 | ${For} $R3 1 10
29 | nsis_tauri_utils::FindProcess "dwall.exe"
30 | Pop $R0
31 | ${If} $R0 != 0
32 | ${Break}
33 | ${EndIf}
34 | Sleep 200
35 | ${Next}
36 | ${EndIf}
37 | !macroend
38 |
39 | !macro NSIS_HOOK_PREINSTALL
40 | # NOTE: Clear incomplete thumbnails saved by old versions
41 | # ---- start: This hook will be removed in the future ----
42 | RMDir /r "$LOCALAPPDATA\dwall"
43 | RMDir /r "$LOCALAPPDATA\com.thep0y.dwall"
44 | # ---- end ----
45 |
46 | !insertmacro TerminateDwallProcess
47 | !macroend
48 |
49 | !macro NSIS_HOOK_PREUNINSTALL
50 | !insertmacro TerminateDwallProcess
51 |
52 | DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "Dwall"
53 | !macroend
54 |
55 | !macro NSIS_HOOK_POSTUNINSTALL
56 | RMDir /r "$APPDATA\dwall"
57 | RMDir /r "$LOCALAPPDATA\dwall"
58 | !macroend
--------------------------------------------------------------------------------
/src/App.css.ts:
--------------------------------------------------------------------------------
1 | import { style } from "@vanilla-extract/css";
2 | import { themeContract } from "fluent-solid/lib/themes";
3 |
4 | export const app = style({
5 | height: "100vh",
6 | });
7 |
8 | export const toastMessageLinkLikeButton = style({
9 | backgroundColor: themeContract.colorTransparentBackground,
10 | border: "none",
11 | textDecoration: "underline",
12 | color: themeContract.colorBrandForegroundLink,
13 | cursor: "pointer",
14 |
15 | selectors: {
16 | "&:hover": {
17 | color: themeContract.colorBrandForegroundLinkHover,
18 | },
19 | "&:active": {
20 | color: themeContract.colorBrandForegroundLinkPressed,
21 | },
22 | },
23 | });
24 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Show } from "solid-js";
2 |
3 | import { LazyFlex } from "~/lazy";
4 |
5 | import Settings from "./components/Settings";
6 | import ThemeShowcase from "./components/ThemeShowcase";
7 | import Updater from "./components/Update";
8 | import Select from "./components/Select";
9 | import Sidebar from "./components/Sidebar";
10 |
11 | import useDark from "~/hooks/useDark";
12 | import { useColorMode } from "./hooks/useColorMode";
13 | import { useAppInitialization } from "./hooks/useAppInitialization";
14 |
15 | import { useMonitor, useTheme, useTranslations, useSettings } from "~/contexts";
16 |
17 | import * as styles from "./App.css";
18 |
19 | const App = () => {
20 | const { translate } = useTranslations();
21 | const theme = useTheme();
22 |
23 | const { currentTheme, downloadThemeID, menuItemIndex, handleThemeSelection } =
24 | theme;
25 |
26 | const {
27 | list: monitors,
28 | handleChange: handleMonitorChange,
29 | id: monitorID,
30 | } = useMonitor();
31 | const { showSettings } = useSettings();
32 |
33 | useDark();
34 | useColorMode();
35 |
36 | useAppInitialization(translate, menuItemIndex, handleThemeSelection);
37 |
38 | return (
39 | <>
40 |
41 |
42 |
43 | }>
44 |
45 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | >
61 | );
62 | };
63 |
64 | export default App;
65 |
--------------------------------------------------------------------------------
/src/commands/autostart.ts:
--------------------------------------------------------------------------------
1 | import { invoke } from "@tauri-apps/api/core";
2 |
3 | export const checkAutoStart = async () => invoke("check_auto_start");
4 |
5 | export const enableAutoStart = async () => invoke("enable_auto_start");
6 |
7 | export const disableAutoStart = async () => invoke("disable_auto_start");
8 |
--------------------------------------------------------------------------------
/src/commands/config.ts:
--------------------------------------------------------------------------------
1 | import { invoke } from "@tauri-apps/api/core";
2 |
3 | export const readConfigFile = async () => invoke("read_config_file");
4 |
5 | export const writeConfigFile = async (config: Config) =>
6 | invoke("write_config_file", { config });
7 |
8 | export const openConfigDir = async () => invoke("open_config_dir");
9 |
--------------------------------------------------------------------------------
/src/commands/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./autostart";
2 | export * from "./config";
3 | export * from "./monitor";
4 | export * from "./system";
5 | export * from "./theme";
6 | export * from "./translation";
7 | export * from "./window";
8 |
--------------------------------------------------------------------------------
/src/commands/monitor.ts:
--------------------------------------------------------------------------------
1 | import { invoke } from "@tauri-apps/api/core";
2 |
3 | export const getMonitors = async () =>
4 | invoke>("get_monitors");
5 |
--------------------------------------------------------------------------------
/src/commands/system.ts:
--------------------------------------------------------------------------------
1 | import { invoke } from "@tauri-apps/api/core";
2 |
3 | export const requestLocationPermission = async () =>
4 | invoke("request_location_permission");
5 |
6 | export const openDir = async (dirPath: string) =>
7 | invoke("open_dir", { dirPath });
8 |
9 | export const killDaemon = async () => invoke("kill_daemon");
10 |
--------------------------------------------------------------------------------
/src/commands/theme.ts:
--------------------------------------------------------------------------------
1 | import { invoke } from "@tauri-apps/api/core";
2 |
3 | export const checkThemeExists = async (
4 | themesDirecotry: string,
5 | themeId: string,
6 | ) => invoke("check_theme_exists", { themesDirecotry, themeId });
7 |
8 | export const applyTheme = async (config: Config) =>
9 | invoke("apply_theme", { config });
10 |
11 | export const getAppliedThemeID = async (monitorId: string) =>
12 | invoke("get_applied_theme_id", { monitorId });
13 |
14 | export const downloadThemeAndExtract = async (
15 | config: Config,
16 | themeId: string,
17 | ) => invoke("download_theme_and_extract", { config, themeId });
18 |
19 | export const cancelThemeDownload = async (themeId: string) =>
20 | invoke("cancel_theme_download", { themeId });
21 |
22 | export const getOrSaveCachedThumbnails = async (
23 | themeId: string,
24 | serialNumber: number,
25 | url: string,
26 | ) =>
27 | invoke("get_or_save_cached_thumbnails", {
28 | themeId,
29 | serialNumber,
30 | url,
31 | });
32 |
33 | export const clearThumbnailCache = async () =>
34 | invoke("clear_thumbnail_cache");
35 |
36 | export const moveThemesDirectory = async (config: Config, dirPath: string) =>
37 | invoke("move_themes_directory", { config, dirPath });
38 |
--------------------------------------------------------------------------------
/src/commands/translation.ts:
--------------------------------------------------------------------------------
1 | import { invoke } from "@tauri-apps/api/core";
2 |
3 | export const getTranslations = async () =>
4 | invoke>("get_translations");
5 |
--------------------------------------------------------------------------------
/src/commands/window.ts:
--------------------------------------------------------------------------------
1 | import { invoke } from "@tauri-apps/api/core";
2 |
3 | export const showWindow = async (label: string) =>
4 | invoke("show_window", { label });
5 |
6 | export const setTitlebarColorMode = async (colorMode: ColorMode) =>
7 | invoke("set_titlebar_color_mode", { colorMode });
8 |
--------------------------------------------------------------------------------
/src/components/DangerButton/DangerButton.css.ts:
--------------------------------------------------------------------------------
1 | import { style } from "@vanilla-extract/css";
2 | import { themeContract } from "fluent-solid/lib/themes";
3 |
4 | export const dangerButtonStyles = style({
5 | backgroundColor: themeContract.colorStatusDangerBackground3,
6 | color: themeContract.colorNeutralForegroundOnBrand,
7 | borderColor: themeContract.colorTransparentStroke,
8 |
9 | selectors: {
10 | "&:hover": {
11 | backgroundColor: themeContract.colorStatusDangerBackground3Hover,
12 | color: themeContract.colorNeutralForegroundOnBrand,
13 | borderColor: themeContract.colorTransparentStroke,
14 | },
15 |
16 | "&:hover:active": {
17 | background: themeContract.colorStatusDangerBackground3Pressed,
18 | color: themeContract.colorNeutralForegroundOnBrand,
19 | borderColor: themeContract.colorTransparentStroke,
20 | },
21 | },
22 | });
23 |
--------------------------------------------------------------------------------
/src/components/DangerButton/DangerButton.tsx:
--------------------------------------------------------------------------------
1 | import { LazyButton } from "~/lazy";
2 | import { dangerButtonStyles } from "./DangerButton.css";
3 | import type { ButtonProps } from "fluent-solid";
4 |
5 | const DangerButton = (props: Omit) => {
6 | return ;
7 | };
8 |
9 | export default DangerButton;
10 |
--------------------------------------------------------------------------------
/src/components/DangerButton/index.ts:
--------------------------------------------------------------------------------
1 | import DangerButton from "./DangerButton";
2 |
3 | export default DangerButton;
4 |
--------------------------------------------------------------------------------
/src/components/Dialog/Dialog.css.ts:
--------------------------------------------------------------------------------
1 | import { keyframes, style } from "@vanilla-extract/css";
2 | import { themeContract, vars } from "fluent-solid/lib/themes";
3 |
4 | const dialogInAnimation = keyframes({
5 | from: {
6 | opacity: 0,
7 | transform: "scale(0.95)",
8 | },
9 | to: {
10 | opacity: 1,
11 | transform: "scale(1)",
12 | },
13 | });
14 |
15 | const base = style({
16 | color: themeContract.colorNeutralForeground1,
17 | backgroundColor: themeContract.colorNeutralBackground1,
18 | position: "relative",
19 | borderRadius: vars.borderRadiusMedium,
20 | boxShadow: themeContract.shadow16,
21 | minWidth: "320px",
22 | maxWidth: "520px",
23 | padding: 0,
24 | zIndex: vars.zIndexModal,
25 |
26 | animationName: dialogInAnimation,
27 | animationDuration: vars.durationNormal,
28 | animationTimingFunction: "ease-out",
29 | });
30 |
31 | const container = style({
32 | position: "fixed",
33 | top: 0,
34 | left: 0,
35 | width: "100%",
36 | height: "100%",
37 | display: "flex",
38 | alignItems: "center",
39 | justifyContent: "center",
40 | zIndex: `calc(${vars.zIndexModal} - 1)`,
41 | });
42 |
43 | const mask = style({
44 | position: "fixed",
45 | top: 0,
46 | left: 0,
47 | width: "100%",
48 | height: "100%",
49 | backgroundColor: themeContract.colorBackgroundOverlay,
50 | zIndex: `calc(${vars.zIndexModal} - 1)`,
51 | });
52 |
53 | const header = style({
54 | padding: "16px 24px",
55 | display: "flex",
56 | alignItems: "center",
57 | justifyContent: "space-between",
58 | userSelect: "none",
59 | });
60 |
61 | const title = style({
62 | margin: 0,
63 | fontSize: vars.fontSizeBase400,
64 | lineHeight: vars.lineHeightBase400,
65 | fontWeight: vars.fontWeightSemibold,
66 | });
67 |
68 | const close = style({
69 | padding: 0,
70 | background: "transparent",
71 | border: "none",
72 | fontSize: vars.fontSizeBase400,
73 | lineHeight: 1,
74 | cursor: "pointer",
75 | transition: "color 0.2s",
76 | });
77 |
78 | const content = style({
79 | padding: "24px",
80 | fontSize: vars.fontSizeBase300,
81 | lineHeight: 1.5,
82 | });
83 |
84 | const footer = style({
85 | padding: "16px 24px",
86 | borderTop: `1px solid ${themeContract.colorNeutralStroke3}`,
87 | textAlign: "right",
88 | });
89 |
90 | export default {
91 | base,
92 | container,
93 | mask,
94 | header,
95 | title,
96 | close,
97 | content,
98 | footer,
99 | };
100 |
--------------------------------------------------------------------------------
/src/components/Dialog/Dialog.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createSignal,
3 | Show,
4 | splitProps,
5 | type Component,
6 | type JSX,
7 | } from "solid-js";
8 |
9 | import { LazyButton, LazyDivider } from "~/lazy";
10 |
11 | import styles from "./Dialog.css";
12 |
13 | interface DialogProps {
14 | open?: boolean;
15 | style?: JSX.CSSProperties;
16 | defaultOpen?: boolean;
17 | onOpenChange?: (open: boolean) => void;
18 | title?: string;
19 | showMask?: boolean;
20 | showCloseButton?: boolean;
21 | maskClosable?: boolean;
22 | footer?: JSX.Element;
23 | children?: JSX.Element;
24 | onClose?: () => void;
25 | }
26 |
27 | const Dialog: Component = (props) => {
28 | const [local, others] = splitProps(props, [
29 | "open",
30 | "defaultOpen",
31 | "onOpenChange",
32 | "title",
33 | "onClose",
34 | "showMask",
35 | "showCloseButton",
36 | "maskClosable",
37 | "footer",
38 | "children",
39 | ]);
40 |
41 | const [internalOpen, setInternalOpen] = createSignal(
42 | local.defaultOpen || false,
43 | );
44 |
45 | const isControlled = () => local.open !== undefined;
46 |
47 | const isOpen = () => (isControlled() ? local.open : internalOpen());
48 |
49 | const handleClose = () => {
50 | if (isControlled()) {
51 | local.onOpenChange?.(false);
52 | } else {
53 | setInternalOpen(false);
54 | }
55 | local.onClose?.();
56 | };
57 |
58 | const handleMaskClick = () => {
59 | if (local.maskClosable !== false) {
60 | handleClose();
61 | }
62 | };
63 |
64 | return (
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
87 |
88 |
89 |
90 |
{local.children}
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 | );
99 | };
100 |
101 | export default Dialog;
102 |
--------------------------------------------------------------------------------
/src/components/Dialog/index.ts:
--------------------------------------------------------------------------------
1 | import Dialog from "./Dialog";
2 |
3 | export default Dialog;
4 |
--------------------------------------------------------------------------------
/src/components/Download/Download.css.ts:
--------------------------------------------------------------------------------
1 | import { style } from "@vanilla-extract/css";
2 | import { vars } from "fluent-solid/lib/themes";
3 |
4 | const downloadContainer = style({
5 | width: "100%",
6 | height: "100%",
7 | flexDirection: "column",
8 | alignItems: "center",
9 | justifyContent: "center",
10 | gap: vars.spacingVerticalS,
11 | });
12 |
13 | const downloadProgress = style({
14 | position: "absolute",
15 | bottom: "36px",
16 | });
17 |
18 | export default { downloadContainer, downloadProgress };
19 |
--------------------------------------------------------------------------------
/src/components/Download/Download.tsx:
--------------------------------------------------------------------------------
1 | import { createEffect, createSignal, onMount, Show } from "solid-js";
2 |
3 | import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
4 | import { message } from "@tauri-apps/plugin-dialog";
5 |
6 | import { LazyProgress } from "~/lazy";
7 | import DangerButton from "~/components/DangerButton";
8 |
9 | import { downloadThemeAndExtract, cancelThemeDownload } from "~/commands";
10 |
11 | import { useConfig, useTheme, useToast, useTranslations } from "~/contexts";
12 |
13 | import styles from "./Download.css";
14 |
15 | interface DownloadProgress {
16 | theme_id: string;
17 | downloaded_bytes: number;
18 | total_bytes: number;
19 | }
20 |
21 | const window = getCurrentWebviewWindow();
22 |
23 | const Download = () => {
24 | const { translate, translateErrorMessage } = useTranslations();
25 | const theme = useTheme();
26 | const { data: config } = useConfig();
27 | const [percent, setPercent] = createSignal();
28 | const [isCancelling, setIsCancelling] = createSignal(false);
29 | const [warning, setWarning] = createSignal();
30 | const toast = useToast();
31 |
32 | const onFinished = () => {
33 | theme.setDownloadThemeID();
34 | theme.handleThemeSelection(theme.menuItemIndex()!);
35 | };
36 |
37 | const handleCancelDownload = async () => {
38 | if (isCancelling()) return;
39 |
40 | setIsCancelling(true);
41 | try {
42 | await cancelThemeDownload(theme.downloadThemeID()!);
43 | // Cancellation request sent, but actual cancellation will be handled by backend
44 | } catch (e) {
45 | message(translateErrorMessage("message-download-faild", e), {
46 | title: translate("title-download-faild"),
47 | kind: "error",
48 | });
49 | }
50 | };
51 |
52 | onMount(async () => {
53 | const unlisten = await window.listen(
54 | "download-theme",
55 | (e) => {
56 | const { total_bytes, downloaded_bytes } = e.payload;
57 | if (total_bytes === 0) {
58 | setWarning(translate("message-file-size-warning"));
59 | setPercent(100);
60 | } else {
61 | setPercent(Math.round((downloaded_bytes / total_bytes) * 1000) / 10);
62 | }
63 | },
64 | );
65 |
66 | try {
67 | await downloadThemeAndExtract(config()!, theme.downloadThemeID()!);
68 | } catch (e) {
69 | // Check if the error is caused by download cancellation
70 | if (String(e).includes("Download cancelled")) {
71 | toast.success(translate("message-download-cancelled"), {
72 | closable: false,
73 | });
74 | } else {
75 | message(translateErrorMessage("message-download-faild", e), {
76 | title: translate("title-download-faild"),
77 | kind: "error",
78 | });
79 | }
80 | } finally {
81 | onFinished();
82 | setPercent();
83 | unlisten();
84 | }
85 | });
86 |
87 | createEffect(
88 | () =>
89 | warning() &&
90 | toast.warning(warning(), {
91 | duration: 10000,
92 | maxWidth: 480,
93 | }),
94 | );
95 |
96 | return (
97 |
98 |
99 |
100 |
101 | {translate("button-cancel") || "Cancel"}
102 |
103 |
104 |
105 | );
106 | };
107 |
108 | export default Download;
109 |
--------------------------------------------------------------------------------
/src/components/Download/index.ts:
--------------------------------------------------------------------------------
1 | import Download from "./Download";
2 |
3 | export default Download;
4 |
--------------------------------------------------------------------------------
/src/components/Flex/Flex.css.ts:
--------------------------------------------------------------------------------
1 | import { style } from "@vanilla-extract/css";
2 |
3 | // Base styles
4 | export const flex = style({
5 | display: "flex",
6 | boxSizing: "border-box",
7 | vars: {
8 | "--flex-grow": "0",
9 | "--flex-shrink": "0",
10 | },
11 | });
12 |
13 | export const inline = style({
14 | display: "inline-flex",
15 | });
16 |
17 | // Direction variants
18 | export const row = style({
19 | flexDirection: "row",
20 | });
21 |
22 | export const rowReverse = style({
23 | flexDirection: "row-reverse",
24 | });
25 |
26 | export const column = style({
27 | flexDirection: "column",
28 | });
29 |
30 | export const columnReverse = style({
31 | flexDirection: "column-reverse",
32 | });
33 |
34 | // Justify content variants
35 | export const justifyStart = style({
36 | justifyContent: "flex-start",
37 | });
38 |
39 | export const justifyEnd = style({
40 | justifyContent: "flex-end",
41 | });
42 |
43 | export const justifyCenter = style({
44 | justifyContent: "center",
45 | });
46 |
47 | export const justifyBetween = style({
48 | justifyContent: "space-between",
49 | });
50 |
51 | export const justifyAround = style({
52 | justifyContent: "space-around",
53 | });
54 |
55 | export const justifyEvenly = style({
56 | justifyContent: "space-evenly",
57 | });
58 |
59 | export const justifyStretch = style({
60 | justifyContent: "stretch",
61 | });
62 |
63 | // Align items variants
64 | export const alignStart = style({
65 | alignItems: "flex-start",
66 | });
67 |
68 | export const alignEnd = style({
69 | alignItems: "flex-end",
70 | });
71 |
72 | export const alignCenter = style({
73 | alignItems: "center",
74 | });
75 |
76 | export const alignStretch = style({
77 | alignItems: "stretch",
78 | });
79 |
80 | export const alignBaseline = style({
81 | alignItems: "baseline",
82 | });
83 |
84 | // Wrap variants
85 | export const wrap = style({
86 | flexWrap: "wrap",
87 | });
88 |
89 | export const nowrap = style({
90 | flexWrap: "nowrap",
91 | });
92 |
93 | export const wrapReverse = style({
94 | flexWrap: "wrap-reverse",
95 | });
96 |
97 | // Gap variants
98 | export const noGap = style({
99 | gap: 0,
100 | });
101 |
102 | export const gapXS = style({
103 | gap: "4px",
104 | });
105 |
106 | export const gapS = style({
107 | gap: "8px",
108 | });
109 |
110 | export const gapM = style({
111 | gap: "12px",
112 | });
113 |
114 | export const gapL = style({
115 | gap: "16px",
116 | });
117 |
118 | export const gapXL = style({
119 | gap: "24px",
120 | });
121 |
122 | export const gapXXL = style({
123 | gap: "32px",
124 | });
125 |
126 | // Grow and shrink
127 | export const grow = style({
128 | flexGrow: "var(--flex-grow, 1)",
129 | });
130 |
131 | export const noGrow = style({
132 | flexGrow: 0,
133 | });
134 |
135 | export const shrink = style({
136 | flexShrink: "var(--flex-shrink, 1)",
137 | });
138 |
139 | export const noShrink = style({
140 | flexShrink: 0,
141 | });
142 |
143 | export const flexValue = style({
144 | flexGrow: "var(--flex-grow)",
145 | flexShrink: "var(--flex-shrink)",
146 | });
147 |
148 | // Padding variants
149 | export const p0 = style({
150 | padding: 0,
151 | });
152 |
153 | export const pXS = style({
154 | padding: "4px",
155 | });
156 |
157 | export const pS = style({
158 | padding: "8px",
159 | });
160 |
161 | export const pM = style({
162 | padding: "12px",
163 | });
164 |
165 | export const pL = style({
166 | padding: "16px",
167 | });
168 |
169 | export const pXL = style({
170 | padding: "24px",
171 | });
172 |
173 | export const pXXL = style({
174 | padding: "32px",
175 | });
176 |
--------------------------------------------------------------------------------
/src/components/Flex/index.ts:
--------------------------------------------------------------------------------
1 | import Flex from "./Flex";
2 |
3 | export type { FlexProps } from "./Flex";
4 |
5 | export default Flex;
6 |
--------------------------------------------------------------------------------
/src/components/Image.tsx:
--------------------------------------------------------------------------------
1 | import { convertFileSrc } from "@tauri-apps/api/core";
2 | import {
3 | children,
4 | createEffect,
5 | createSignal,
6 | mergeProps,
7 | onCleanup,
8 | } from "solid-js";
9 | import { getOrSaveCachedThumbnails } from "~/commands";
10 | import { LazySpinner } from "~/lazy";
11 |
12 | interface ImageData {
13 | width: number;
14 | height: number;
15 | }
16 |
17 | interface ImageProps {
18 | themeID: string;
19 | serialNumber: number;
20 | src: string;
21 | alt?: string;
22 | width?: number;
23 | height?: number;
24 | class?: string;
25 | onLoad?: (data: ImageData) => void;
26 | onError?: (error: Error) => void;
27 | fallbackSrc?: string;
28 | retryCount?: number;
29 | }
30 |
31 | const Image = (props: ImageProps) => {
32 | let imageRef: HTMLImageElement | undefined;
33 | const [loaded, setLoaded] = createSignal(false);
34 | const [error, setError] = createSignal(null);
35 | const [retryAttempts, setRetryAttempts] = createSignal(0);
36 | const merged = mergeProps({ retryCount: 3 }, props);
37 |
38 | const [currentSrc, setCurrentSrc] = createSignal(null);
39 |
40 | const handleLoad = () => {
41 | if (imageRef?.src) {
42 | setLoaded(true);
43 | setError(null);
44 | props.onLoad?.({
45 | width: imageRef.naturalWidth,
46 | height: imageRef.naturalHeight,
47 | });
48 | }
49 | };
50 |
51 | const handleError = () => {
52 | const currentAttempts = retryAttempts();
53 | if (currentAttempts < merged.retryCount) {
54 | setRetryAttempts(currentAttempts + 1);
55 | // Retry loading
56 | loadImage();
57 | } else {
58 | const err = new Error(
59 | `Failed to load image after ${merged.retryCount} attempts`,
60 | );
61 | setError(err);
62 | props.onError?.(err);
63 |
64 | if (props.fallbackSrc) {
65 | setCurrentSrc(props.fallbackSrc);
66 | }
67 | }
68 | };
69 |
70 | const loadImage = async () => {
71 | try {
72 | const path = await getOrSaveCachedThumbnails(
73 | props.themeID,
74 | props.serialNumber,
75 | props.src,
76 | );
77 | const src = convertFileSrc(path);
78 | setCurrentSrc(src);
79 | } catch (err) {
80 | handleError();
81 | }
82 | };
83 |
84 | createEffect(() => {
85 | if (!imageRef) return;
86 |
87 | const observer = new IntersectionObserver((entries) => {
88 | for (const entry of entries) {
89 | if (entry.isIntersecting && !currentSrc()) {
90 | loadImage();
91 | observer.unobserve(entry.target);
92 | }
93 | }
94 | });
95 |
96 | observer.observe(imageRef);
97 |
98 | onCleanup(() => {
99 | observer.disconnect();
100 | });
101 | });
102 |
103 | const resolved = children(() => (
104 | <>
105 | {!loaded() && !error() && (
106 |
114 |
115 |
116 | )}
117 |
126 | {error() && !props.fallbackSrc && Failed to load image
}
127 | >
128 | ));
129 |
130 | return (
131 |
142 | {resolved()}
143 |
144 | );
145 | };
146 |
147 | export default Image;
148 |
--------------------------------------------------------------------------------
/src/components/ImageCarousel/ImageCarousel.css.ts:
--------------------------------------------------------------------------------
1 | import { globalStyle, style } from "@vanilla-extract/css";
2 | import { themeContract, vars } from "fluent-solid/lib/themes";
3 | import { appVars } from "~/themes/vars.css";
4 |
5 | export const carousel = style({
6 | width: appVars.contentWidth,
7 | height: "480px",
8 | borderRadius: vars.borderRadiusMedium,
9 | display: "flex",
10 | alignItems: "center",
11 | justifyContent: "center",
12 | });
13 |
14 | export const wrapper = style({
15 | width: "480px",
16 | height: "auto",
17 | minHeight: "100px",
18 | maxHeight: "480px",
19 | position: "relative",
20 | borderRadius: vars.borderRadiusMedium,
21 | background: themeContract.colorNeutralBackground1,
22 | boxShadow: themeContract.shadow4Brand,
23 | overflow: "hidden",
24 | transition: `box-shadow ${vars.durationNormal} ease-in-out`,
25 |
26 | selectors: {
27 | "&:hover": {
28 | boxShadow: themeContract.shadow8,
29 | },
30 | },
31 | });
32 |
33 | export const track = style({
34 | position: "relative",
35 | width: "100%",
36 | height: "100%",
37 | });
38 |
39 | export const slide = style({
40 | position: "absolute",
41 | opacity: 0,
42 | width: "100%",
43 | top: "50%",
44 | transform: "translateY(-50%) scale(1.05)",
45 | transition: `all ${vars.durationSlow} ease-out`,
46 | display: "flex",
47 | alignItems: "center",
48 | justifyContent: "center",
49 | });
50 |
51 | export const activeSlide = style({
52 | opacity: 1,
53 | transform: "translateY(-50%) scale(1)",
54 | });
55 |
56 | export const image = style({
57 | display: "flex",
58 | alignItems: "center",
59 | justifyContent: "center",
60 | });
61 |
62 | globalStyle(`${image} img`, {
63 | maxWidth: "100%",
64 | maxHeight: "100%",
65 | width: "auto",
66 | height: "auto",
67 | objectFit: "contain",
68 | });
69 |
70 | export const controls = style({
71 | opacity: 0,
72 | transition: `opacity ${vars.durationNormal} ease-in-out`,
73 | });
74 |
75 | export const visibleControls = style({
76 | opacity: 1,
77 | });
78 |
79 | export const button = style({
80 | position: "absolute",
81 | top: "50%",
82 | transform: "translateY(-50%)",
83 | zIndex: 2,
84 | backgroundColor: themeContract.colorNeutralBackgroundAlpha,
85 | color: themeContract.colorNeutralForeground1,
86 | border: "none",
87 | display: "flex",
88 | alignItems: "center",
89 | justifyContent: "center",
90 | cursor: "pointer",
91 | backdropFilter: "blur(4px)",
92 | transition: `all ${vars.durationNormal} ease`,
93 |
94 | selectors: {
95 | "&:hover": {
96 | background: themeContract.colorNeutralBackgroundAlpha2,
97 | transform: "translateY(-50%) scale(1.05)",
98 | },
99 | },
100 | });
101 |
102 | export const prevButton = style({
103 | left: "16px",
104 | });
105 |
106 | export const nextButton = style({
107 | right: "16px",
108 | });
109 |
110 | export const indicators = style({
111 | position: "absolute",
112 | left: "50%",
113 | transform: "translateX(-50%)",
114 | display: "flex",
115 | gap: vars.spacingHorizontalS,
116 | zIndex: 2,
117 | padding: vars.spacingHorizontalS,
118 | backgroundColor: "rgba(255, 255, 255, 0.2)",
119 | backdropFilter: "blur(4px)",
120 | borderRadius: "16px",
121 |
122 | "@media": {
123 | "(prefers-color-scheme: dark)": {
124 | backgroundColor: "rgba(0, 0, 0, 0.3)",
125 | },
126 | },
127 | });
128 |
129 | export const activeIndicator = style({});
130 |
131 | export const indicator = style({
132 | width: "8px",
133 | height: "8px",
134 | borderRadius: "50%",
135 | border: "none",
136 | backgroundColor: "rgba(255, 255, 255, 0.5)",
137 | cursor: "pointer",
138 | padding: 0,
139 | transition: `all ${vars.durationNormal} ease`,
140 |
141 | "@media": {
142 | "(prefers-color-scheme: dark)": {
143 | backgroundColor: "rgba(255, 255, 255, 0.3)",
144 | },
145 | },
146 |
147 | selectors: {
148 | "&:hover": {
149 | backgroundColor: "rgba(255, 255, 255, 0.8)",
150 |
151 | "@media": {
152 | "(prefers-color-scheme: dark)": {
153 | backgroundColor: "rgba(255, 255, 255, 0.5)",
154 | },
155 | },
156 | },
157 |
158 | [`&${activeIndicator}`]: {
159 | backgroundColor: "#fff",
160 | transform: "scale(1.2)",
161 |
162 | "@media": {
163 | "(prefers-color-scheme: dark)": {
164 | backgroundColor: "rgba(255, 255, 255, 0.7)",
165 | },
166 | },
167 | },
168 | },
169 | });
170 |
--------------------------------------------------------------------------------
/src/components/ImageCarousel/ImageCarousel.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createSignal,
3 | createEffect,
4 | onCleanup,
5 | createMemo,
6 | For,
7 | } from "solid-js";
8 | import { BiSolidChevronLeft, BiSolidChevronRight } from "solid-icons/bi";
9 |
10 | import { useConfig, useTheme } from "~/contexts";
11 |
12 | import { LazyButton } from "~/lazy";
13 |
14 | import Image from "~/components/Image";
15 |
16 | import { generateGitHubThumbnailMirrorUrl } from "~/utils/proxy";
17 |
18 | import * as styles from "./ImageCarousel.css";
19 |
20 | interface ImageCarouselProps {
21 | interval?: number;
22 | }
23 |
24 | export default function ImageCarousel(props: ImageCarouselProps) {
25 | const theme = useTheme();
26 | const { data: config } = useConfig();
27 |
28 | const [currentIndex, setCurrentIndex] = createSignal(0);
29 | const [isPlaying, setIsPlaying] = createSignal(true);
30 | const [indicatorsBottom, setIndicatorsBottom] = createSignal(10);
31 | const [isHovered, setIsHovered] = createSignal(false);
32 | const [wrapperHeight, setWrapperHeight] = createSignal("auto");
33 |
34 | const images = createMemo(() => {
35 | const currentConfig = config();
36 | if (!currentConfig) return [];
37 |
38 | return theme.currentTheme()!.thumbnail.map((src) => ({
39 | src: generateGitHubThumbnailMirrorUrl(
40 | src,
41 | currentConfig.github_mirror_template,
42 | ),
43 | alt: theme.currentTheme()!.id,
44 | }));
45 | });
46 |
47 | // reset index
48 | createEffect(() => images() && setCurrentIndex(0));
49 |
50 | createEffect(() => {
51 | if (!isPlaying()) return;
52 |
53 | const timer = setInterval(() => {
54 | nextImage();
55 | }, props.interval || 3000);
56 |
57 | onCleanup(() => clearInterval(timer));
58 | });
59 |
60 | const nextImage = () => {
61 | setCurrentIndex((current) =>
62 | current === images().length - 1 ? 0 : current + 1,
63 | );
64 | };
65 |
66 | const prevImage = () => {
67 | setCurrentIndex((current) =>
68 | current === 0 ? images().length - 1 : current - 1,
69 | );
70 | };
71 |
72 | const goToImage = (index: number) => {
73 | setCurrentIndex(index);
74 | };
75 |
76 | const handleMouseEnter = () => {
77 | setIsPlaying(false);
78 | setIsHovered(true);
79 | };
80 |
81 | const handleMouseLeave = () => {
82 | setIsPlaying(true);
83 | setIsHovered(false);
84 | };
85 |
86 | return (
87 |
88 |
94 |
95 | {(image, index) => (
96 |
99 | {
105 | const clientHeight = height / (width / 480);
106 | setWrapperHeight(`${clientHeight}px`);
107 | setIndicatorsBottom(10);
108 | }}
109 | />
110 |
111 | )}
112 |
113 |
114 |
117 | }
120 | shape="circular"
121 | onClick={prevImage}
122 | />
123 |
124 | }
127 | shape="circular"
128 | onClick={nextImage}
129 | />
130 |
131 |
132 |
136 |
137 | {(_, index) => (
138 |
146 |
147 |
148 |
149 | );
150 | }
151 |
--------------------------------------------------------------------------------
/src/components/ImageCarousel/index.ts:
--------------------------------------------------------------------------------
1 | import ImageCarousel from "./ImageCarousel";
2 |
3 | export default ImageCarousel;
4 |
--------------------------------------------------------------------------------
/src/components/NumericInput/NumericInput/NumericInput.css.ts:
--------------------------------------------------------------------------------
1 | import { style } from "@vanilla-extract/css";
2 | import { vars } from "fluent-solid/lib/themes";
3 |
4 | export const inputWrapper = style({
5 | display: "flex",
6 | alignItems: "center",
7 | border: "1px solid #8a8886",
8 | borderRadius: vars.borderRadiusMedium,
9 | padding: "0 8px",
10 | height: "32px",
11 | background: "#ffffff",
12 | transition: "all 0.1s ease",
13 |
14 | selectors: {
15 | "&:hover": {
16 | borderColor: "#323130",
17 | },
18 | },
19 | });
20 |
21 | export const focused = style({
22 | borderColor: "#0078d4",
23 | boxShadow: "0 0 0 1px #0078d4",
24 | });
25 |
26 | export const disabled = style({
27 | background: "#f3f2f1",
28 | borderColor: "#c8c6c4",
29 | cursor: "not-allowed",
30 | });
31 |
32 | export const input = style({
33 | flex: 1,
34 | outline: "none",
35 | background: "transparent",
36 | fontSize: "14px",
37 | color: "#323130",
38 | padding: 0,
39 |
40 | selectors: {
41 | "&::placeholder": {
42 | color: "#a19f9d",
43 | },
44 | "&:disabled": {
45 | cursor: "not-allowed",
46 | color: "#a19f9d",
47 | },
48 | },
49 | });
50 |
51 | export const suffix = style({
52 | fontSize: "14px",
53 | color: "#605e5c",
54 | marginLeft: "4px",
55 | selectors: {
56 | [`${disabled} &`]: {
57 | color: "#a19f9d",
58 | },
59 | },
60 | });
61 |
--------------------------------------------------------------------------------
/src/components/NumericInput/NumericInput/NumericInput.tsx:
--------------------------------------------------------------------------------
1 | import { createEffect, type Component } from "solid-js";
2 | import * as styles from "./NumericInput.css";
3 | import { LazyInput } from "~/lazy";
4 | import type { NumericInputProps } from "./NumericInput.types";
5 | import useNumericInputHandling, { numberValidation } from "./useNumericInput";
6 | import InputContainer from "../NumericInputContainer";
7 |
8 | const NumericInput: Component = (props) => {
9 | const {
10 | inputValue,
11 | warning,
12 | handleBlur,
13 | handleInput,
14 | handleChange,
15 | setInputValue,
16 | setWarning,
17 | tooSmallMessage,
18 | tooLargeMessage,
19 | } = useNumericInputHandling(props);
20 |
21 | createEffect(() => {
22 | if (props.value !== undefined) {
23 | const { message, value } = numberValidation.validateRange(
24 | props.value,
25 | {
26 | value: props.min,
27 | warning: tooSmallMessage,
28 | },
29 | {
30 | value: props.max,
31 | warning: tooLargeMessage,
32 | },
33 | );
34 | setWarning(message);
35 | setInputValue(value.toString());
36 | }
37 | });
38 |
39 | return (
40 |
45 |
46 |
62 |
63 |
64 | );
65 | };
66 |
67 | export default NumericInput;
68 |
--------------------------------------------------------------------------------
/src/components/NumericInput/NumericInput/NumericInput.types.ts:
--------------------------------------------------------------------------------
1 | import type { InputProps } from "fluent-solid/lib/components/input";
2 |
3 | export interface NumericInputProps {
4 | label?: string;
5 | value?: number;
6 | onChange?: (value?: number) => void;
7 | onInput?: (value?: number) => void;
8 | disabled?: boolean;
9 | required?: boolean;
10 | min?: number;
11 | max?: number;
12 | size?: InputProps["size"];
13 | appearance?: InputProps["appearance"];
14 | placeholder?: InputProps["placeholder"];
15 | style?: InputProps["style"];
16 | contentAfter?: InputProps["contentAfter"];
17 | autofocus?: boolean;
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/NumericInput/NumericInput/index.ts:
--------------------------------------------------------------------------------
1 | import NumericInput from "./NumericInput";
2 |
3 | export default NumericInput;
4 |
--------------------------------------------------------------------------------
/src/components/NumericInput/NumericInput/useNumericInput.ts:
--------------------------------------------------------------------------------
1 | import { createSignal } from "solid-js";
2 | import type { NumericInputProps } from "./NumericInput.types";
3 |
4 | import { useTranslations } from "~/contexts";
5 |
6 | export const numberValidation = {
7 | isValidNumberInput: (value: string): boolean => {
8 | if (value === "" || value === "-" || value === ".") return true;
9 | return (
10 | /^-?\d*\.?\d*$/.test(value) && !Number.isNaN(Number.parseFloat(value))
11 | );
12 | },
13 |
14 | validateRange: (
15 | value: number,
16 | min?: { value?: number; warning: string },
17 | max?: { value?: number; warning: string },
18 | ): {
19 | isValid: boolean;
20 | message: string;
21 | value: number;
22 | } => {
23 | if (min?.value !== undefined && value < min.value) {
24 | return { isValid: false, message: min.warning, value: min.value };
25 | }
26 | if (max?.value !== undefined && value > max.value) {
27 | return { isValid: false, message: max.warning, value: max.value };
28 | }
29 | return { isValid: true, message: "", value };
30 | },
31 | };
32 |
33 | const useNumericInputHandling = (props: NumericInputProps) => {
34 | const { translate } = useTranslations();
35 | const [inputValue, setInputValue] = createSignal(
36 | props.value?.toString() || "",
37 | );
38 | const [warning, setWarning] = createSignal("");
39 |
40 | const invalidNumberMessage = translate("message-invalid-number-input");
41 | const tooSmallMessage = props.min
42 | ? translate("message-number-too-small", {
43 | min: String(props.min),
44 | })
45 | : "";
46 | const tooLargeMessage = props.max
47 | ? translate("message-number-too-large", {
48 | max: String(props.max),
49 | })
50 | : "";
51 |
52 | const handleBlur = () => {
53 | const value = inputValue();
54 | if (value === "" || value === "-" || value === ".") {
55 | setInputValue("");
56 | props.onChange?.();
57 | return;
58 | }
59 |
60 | if (!numberValidation.isValidNumberInput(value)) {
61 | setWarning(invalidNumberMessage);
62 | return;
63 | }
64 |
65 | const numValue = Number.parseFloat(value);
66 | const { message, value: validatedValue } = numberValidation.validateRange(
67 | numValue,
68 | {
69 | value: props.min,
70 | warning: tooSmallMessage,
71 | },
72 | {
73 | value: props.max,
74 | warning: tooLargeMessage,
75 | },
76 | );
77 | if (message) {
78 | setWarning(message);
79 | props.onChange?.();
80 | } else {
81 | setWarning("");
82 | props.onChange?.(validatedValue);
83 | }
84 | setInputValue(validatedValue.toString());
85 | };
86 |
87 | const handleCommonLogic = (
88 | value: string,
89 | callback?: (value?: number) => void,
90 | ) => {
91 | if (value === "" || value === "-" || value === ".") {
92 | setInputValue(value);
93 | setWarning("");
94 | callback?.();
95 | return;
96 | }
97 |
98 | if (!numberValidation.isValidNumberInput(value)) {
99 | setWarning(invalidNumberMessage);
100 | callback?.();
101 | return;
102 | }
103 |
104 | const numValue = Number.parseFloat(value);
105 | const { message } = numberValidation.validateRange(
106 | numValue,
107 | {
108 | value: props.min,
109 | warning: tooSmallMessage,
110 | },
111 | {
112 | value: props.max,
113 | warning: tooLargeMessage,
114 | },
115 | );
116 |
117 | if (message) {
118 | setWarning(message);
119 | callback?.();
120 | } else {
121 | setWarning("");
122 | callback?.(numValue);
123 | }
124 | setInputValue(value);
125 | };
126 |
127 | const handleInput = (value: string) => {
128 | handleCommonLogic(value, props.onInput);
129 | };
130 |
131 | const handleChange = (value: string) => {
132 | handleCommonLogic(value, props.onChange);
133 | };
134 | return {
135 | inputValue,
136 | setInputValue,
137 | warning,
138 | setWarning,
139 | handleBlur,
140 | handleInput,
141 | handleChange,
142 | tooSmallMessage,
143 | tooLargeMessage,
144 | };
145 | };
146 |
147 | export default useNumericInputHandling;
148 |
--------------------------------------------------------------------------------
/src/components/NumericInput/NumericInputContainer/NumericInputContainer.css.ts:
--------------------------------------------------------------------------------
1 | import { style } from "@vanilla-extract/css";
2 | import { vars } from "fluent-solid/lib/themes";
3 |
4 | export const container = style({
5 | display: "flex",
6 | flexDirection: "column",
7 | gap: vars.spacingVerticalXS,
8 | position: "relative",
9 | });
10 |
11 | export const warningMessage = style({
12 | position: "absolute",
13 | left: 0,
14 | top: "100%",
15 | color: "#d83b01",
16 | marginTop: "4px",
17 | lineHeight: 1.2,
18 | whiteSpace: "nowrap",
19 | fontSize: "0.6rem",
20 | });
21 |
--------------------------------------------------------------------------------
/src/components/NumericInput/NumericInputContainer/NumericInputContainer.tsx:
--------------------------------------------------------------------------------
1 | import { Show, type Component } from "solid-js";
2 | import type { JSX } from "solid-js";
3 | import * as styles from "./NumericInputContainer.css";
4 | import { LazyLabel } from "~/lazy";
5 |
6 | const NumericInputContainer: Component<{
7 | children: JSX.Element;
8 | label?: string;
9 | required?: boolean;
10 | warning?: string;
11 | }> = (props) => (
12 |
13 |
14 | {props.label}
15 |
16 | {props.children}
17 |
18 | {props.warning}
19 |
20 |
21 | );
22 |
23 | export default NumericInputContainer;
24 |
--------------------------------------------------------------------------------
/src/components/NumericInput/NumericInputContainer/index.ts:
--------------------------------------------------------------------------------
1 | import NumericInputContainer from "./NumericInputContainer";
2 |
3 | export default NumericInputContainer;
4 |
--------------------------------------------------------------------------------
/src/components/NumericInput/index.ts:
--------------------------------------------------------------------------------
1 | import NumericInput from "./NumericInput";
2 |
3 | export default NumericInput;
4 |
--------------------------------------------------------------------------------
/src/components/Select/Select/Select.types.ts:
--------------------------------------------------------------------------------
1 | import type { JSX } from "solid-js";
2 |
3 | export interface SelectOption {
4 | value: string;
5 | label: string;
6 | disabled?: boolean;
7 | }
8 |
9 | export interface SelectProps {
10 | label?: string;
11 | value: string;
12 | onChange?: (value: string) => void;
13 | options: SelectOption[];
14 | disabled?: boolean;
15 | required?: boolean;
16 | placeholder?: string;
17 | style?: JSX.CSSProperties;
18 | size?: "small" | "medium" | "large";
19 | appearance?: "outline" | "underline" | "filled-darker" | "filled-lighter";
20 | autofocus?: boolean;
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/Select/Select/index.ts:
--------------------------------------------------------------------------------
1 | import Select from "./Select";
2 |
3 | export default Select;
4 |
--------------------------------------------------------------------------------
/src/components/Select/SelectContainer/SelectContainer.css.ts:
--------------------------------------------------------------------------------
1 | import { style } from "@vanilla-extract/css";
2 | import { themeContract, vars } from "fluent-solid/lib/themes";
3 |
4 | export const container = style({
5 | display: "flex",
6 | alignItems: "center",
7 | justifyContent: "center",
8 | gap: vars.spacingHorizontalXS,
9 | position: "relative",
10 | });
11 |
12 | export const warningMessage = style({
13 | position: "absolute",
14 | left: 0,
15 | top: "100%",
16 | color: themeContract.colorStatusDangerForeground1,
17 | marginTop: "4px",
18 | lineHeight: 1.2,
19 | whiteSpace: "nowrap",
20 | fontSize: "0.6rem",
21 | });
22 |
--------------------------------------------------------------------------------
/src/components/Select/SelectContainer/SelectContainer.tsx:
--------------------------------------------------------------------------------
1 | import { Show, type Component } from "solid-js";
2 | import { LazyLabel } from "~/lazy";
3 | import type { SelectContainerProps } from "./SelectContainer.types";
4 | import * as styles from "./SelectContainer.css";
5 |
6 | const SelectContainer: Component = (props) => (
7 |
8 |
9 |
10 | {props.label}
11 |
12 |
13 | {props.children}
14 |
15 | {props.warning}
16 |
17 |
18 | );
19 |
20 | export default SelectContainer;
21 |
--------------------------------------------------------------------------------
/src/components/Select/SelectContainer/SelectContainer.types.ts:
--------------------------------------------------------------------------------
1 | import type { JSX } from "solid-js";
2 |
3 | export type SelectContainerProps = {
4 | children: JSX.Element;
5 | label?: string;
6 | required?: boolean;
7 | warning?: string;
8 | labelId?: string;
9 | };
10 |
--------------------------------------------------------------------------------
/src/components/Select/SelectContainer/index.ts:
--------------------------------------------------------------------------------
1 | import SelectContainer from "./SelectContainer";
2 |
3 | export default SelectContainer;
4 |
--------------------------------------------------------------------------------
/src/components/Select/index.ts:
--------------------------------------------------------------------------------
1 | import Select from "./Select";
2 |
3 | export default Select;
4 |
--------------------------------------------------------------------------------
/src/components/Settings/AutoDetectColorMode.tsx:
--------------------------------------------------------------------------------
1 | import { message } from "@tauri-apps/plugin-dialog";
2 |
3 | import SettingsItem from "./Item";
4 | import { LazySwitch } from "~/lazy";
5 |
6 | import { writeConfigFile } from "~/commands";
7 |
8 | import { useConfig, useTranslations } from "~/contexts";
9 |
10 | const AutoDetectColorMode = () => {
11 | const { data: config, refetch: refetchConfig } = useConfig();
12 | const { translate, translateErrorMessage } = useTranslations();
13 |
14 | const onSwitchAutoDetectColorMode = async () => {
15 | try {
16 | await writeConfigFile({
17 | ...config()!,
18 | auto_detect_color_mode: !config()!.auto_detect_color_mode,
19 | });
20 | refetchConfig();
21 | } catch (error) {
22 | message(
23 | translateErrorMessage(
24 | "message-switch-auto-light-dark-mode-failed",
25 | error,
26 | ),
27 | { kind: "error" },
28 | );
29 | }
30 | };
31 |
32 | return (
33 |
37 |
41 |
42 | );
43 | };
44 |
45 | export default AutoDetectColorMode;
46 |
--------------------------------------------------------------------------------
/src/components/Settings/AutoStart.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal, onMount } from "solid-js";
2 |
3 | import { message } from "@tauri-apps/plugin-dialog";
4 |
5 | import SettingsItem from "./Item";
6 | import { LazySwitch } from "~/lazy";
7 |
8 | import { checkAutoStart, disableAutoStart, enableAutoStart } from "~/commands";
9 |
10 | import { useTranslations } from "~/contexts";
11 |
12 | const AutoStart = () => {
13 | const { translate, translateErrorMessage } = useTranslations();
14 | const [autoStartState, setAutoStartState] = createSignal(false);
15 |
16 | onMount(async () => {
17 | const state = await checkAutoStart();
18 | setAutoStartState(state);
19 | });
20 |
21 | const onSwitchAutoStart = async () => {
22 | if (autoStartState()) {
23 | try {
24 | await disableAutoStart();
25 | } catch (e) {
26 | message(translateErrorMessage("message-disable-startup-failed", e), {
27 | kind: "error",
28 | });
29 | return;
30 | }
31 | } else {
32 | try {
33 | await enableAutoStart();
34 | } catch (e) {
35 | message(translateErrorMessage("message-startup-failed", e), {
36 | kind: "error",
37 | });
38 | return;
39 | }
40 | }
41 | setAutoStartState((prev) => !prev);
42 | };
43 |
44 | return (
45 |
50 |
51 |
52 | );
53 | };
54 |
55 | export default AutoStart;
56 |
--------------------------------------------------------------------------------
/src/components/Settings/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { createResource } from "solid-js";
2 | import { AiFillGithub } from "solid-icons/ai";
3 |
4 | import { getVersion } from "@tauri-apps/api/app";
5 | import { ask, message } from "@tauri-apps/plugin-dialog";
6 | import { open } from "@tauri-apps/plugin-shell";
7 |
8 | import { openConfigDir } from "~/commands";
9 | import { useTranslations, useUpdate } from "~/contexts";
10 | import {
11 | LazyButton,
12 | LazyFlex,
13 | LazyLabel,
14 | LazySpace,
15 | LazyTooltip,
16 | } from "~/lazy";
17 |
18 | const SettingsFooter = () => {
19 | const [version] = createResource(getVersion);
20 | const { translate } = useTranslations();
21 | const { update: resource, recheckUpdate, setShowUpdateDialog } = useUpdate();
22 |
23 | const onOpenLogDir = async () => {
24 | await openConfigDir();
25 | };
26 |
27 | const onUpdate = async () => {
28 | if (!resource()) {
29 | recheckUpdate();
30 | }
31 | const update = resource();
32 | if (!update) {
33 | if (update === null)
34 | await message(translate("message-version-is-the-latest"));
35 | return;
36 | }
37 |
38 | const { currentVersion, version, body } = update;
39 |
40 | const result = await ask(
41 | `Current version ${currentVersion}, new version ${version} available.\n\nChangelog:\n\n${body}\n\nUpdate now?`,
42 | "Dwall",
43 | );
44 | if (!result) return;
45 | setShowUpdateDialog(true);
46 | };
47 |
48 | const onOpenGithub = () => open("https://github.com/dwall-rs/dwall");
49 |
50 | return (
51 |
52 |
53 | {translate("label-version")}
54 |
55 |
60 |
65 | {version()}
66 |
67 |
68 |
69 |
70 |
71 | {translate("label-source-code")}
72 |
73 | }
76 | onClick={onOpenGithub}
77 | />
78 |
79 |
80 |
81 |
82 | {translate("button-open-log-directory")}
83 |
84 |
85 |
86 | );
87 | };
88 |
89 | export default SettingsFooter;
90 |
--------------------------------------------------------------------------------
/src/components/Settings/GithubMirror.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal } from "solid-js";
2 | import { AiFillSave } from "solid-icons/ai";
3 |
4 | import { open } from "@tauri-apps/plugin-shell";
5 |
6 | import { LazyButton, LazyInput } from "~/lazy";
7 | import SettingsItem from "./Item";
8 |
9 | import { writeConfigFile } from "~/commands";
10 |
11 | import { useToast, useConfig, useTranslations } from "~/contexts";
12 |
13 | const GithubMirror = () => {
14 | const { translate } = useTranslations();
15 | const toast = useToast();
16 | const { data: config, refetch: refetchConfig } = useConfig();
17 |
18 | const [value, setValue] = createSignal(config()?.github_mirror_template);
19 |
20 | const handleInput = (v: string) => {
21 | setValue(v);
22 | };
23 |
24 | const onConfirm = async () => {
25 | await writeConfigFile({ ...config()!, github_mirror_template: value() });
26 | refetchConfig();
27 | toast.success(
28 | translate("message-github-mirror-template-updated", {
29 | newTemplate: value() ?? "",
30 | }),
31 | );
32 | };
33 |
34 | return (
35 |
41 | open(
42 | "https://gh-proxy.com/gist.githubusercontent.com/thep0y/682ebeb2b8d4f6eea3841fe3f42c0e30/raw/2f5b641e77abe3cb8f74ee8f65ead95beb663444/markdown",
43 | ),
44 | }}
45 | vertical
46 | >
47 | }
56 | onClick={onConfirm}
57 | size="small"
58 | />
59 | }
60 | />
61 |
62 | );
63 | };
64 |
65 | export default GithubMirror;
66 |
--------------------------------------------------------------------------------
/src/components/Settings/Interval.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal } from "solid-js";
2 | import { AiFillSave } from "solid-icons/ai";
3 |
4 | import { LazyButton, LazySpace } from "~/lazy";
5 | import SettingsItem from "./Item";
6 |
7 | import NumericInput from "~/components/NumericInput";
8 |
9 | import { writeConfigFile } from "~/commands";
10 |
11 | import { useConfig, useToast, useTranslations } from "~/contexts";
12 |
13 | const Interval = () => {
14 | const toast = useToast();
15 | const { data: config, refetch: refetchConfig } = useConfig();
16 | const { translate } = useTranslations();
17 |
18 | const [value, setValue] = createSignal(config()?.interval);
19 |
20 | const onSave = async () => {
21 | await writeConfigFile({ ...config()!, interval: value()! });
22 | refetchConfig();
23 | toast.success(
24 | translate("message-check-interval-updated", {
25 | newInterval: value()!.toString(),
26 | }),
27 | );
28 | };
29 |
30 | const onChange = async (v?: number) => {
31 | setValue(v);
32 | };
33 |
34 | return (
35 |
36 |
45 | {translate("unit-second")}
46 | }
50 | appearance="subtle"
51 | size="small"
52 | />
53 |
54 | }
55 | />
56 |
57 | );
58 | };
59 |
60 | export default Interval;
61 |
--------------------------------------------------------------------------------
/src/components/Settings/Item/SettingsItem.css.ts:
--------------------------------------------------------------------------------
1 | import { globalStyle, style } from "@vanilla-extract/css";
2 |
3 | export const settingsItemContentWrapper = style({});
4 |
5 | export const settingsItemContentHelpButton = style({});
6 |
7 | globalStyle(`${settingsItemContentHelpButton} span`, {
8 | fontSize: "0.8rem",
9 | });
10 |
--------------------------------------------------------------------------------
/src/components/Settings/Item/SettingsItem.tsx:
--------------------------------------------------------------------------------
1 | import { children, type JSXElement } from "solid-js";
2 | import {
3 | LazyButton,
4 | LazyFlex,
5 | LazyLabel,
6 | LazySpace,
7 | LazyTooltip,
8 | } from "~/lazy";
9 | import { AiOutlineInfoCircle } from "solid-icons/ai";
10 | import * as styles from "./SettingsItem.css";
11 |
12 | interface BaseProps {
13 | label: string;
14 | children: JSXElement;
15 | help?: JSXElement | { content: JSXElement; onClick: () => void };
16 | helpPosition?: "above" | "below";
17 | }
18 |
19 | interface VerticalLayout {
20 | layout: "vertical";
21 | vertical: true;
22 | extra?: never;
23 | }
24 |
25 | interface HorizontalLayout {
26 | layout: "horizontal";
27 | vertical?: never;
28 | extra?: JSXElement;
29 | }
30 |
31 | interface DefaultLayout {
32 | layout?: never;
33 | vertical?: never;
34 | extra?: never;
35 | }
36 |
37 | type LayoutConfig = VerticalLayout | HorizontalLayout | DefaultLayout;
38 | type SettingsItemProps = BaseProps & LayoutConfig;
39 |
40 | const labelStyles = {
41 | display: "flex",
42 | "justify-items": "center",
43 | "align-items": "center",
44 | } as const;
45 |
46 | const SettingsItem = (props: SettingsItemProps) => {
47 | const renderLabel = children(() =>
48 | props.help ? (
49 |
50 |
51 | {props.label}
52 |
53 |
54 |
64 | }
66 | onClick={
67 | typeof props.help === "object" && "content" in props.help
68 | ? props.help.onClick
69 | : undefined
70 | }
71 | shape="circular"
72 | size="small"
73 | appearance="transparent"
74 | class={styles.settingsItemContentHelpButton}
75 | />
76 |
77 |
78 | ) : (
79 |
80 | {props.label}
81 |
82 | ),
83 | );
84 |
85 | const renderContent = children(() => {
86 | if (props.layout === "vertical") {
87 | return (
88 |
89 | {renderLabel()}
90 | {props.children}
91 |
92 | );
93 | }
94 |
95 | const mainContent = (
96 |
97 | {renderLabel()}
98 | {props.children}
99 |
100 | );
101 |
102 | if (props.layout === "horizontal" && props.extra) {
103 | return (
104 |
105 | {mainContent}
106 | {props.extra}
107 |
108 | );
109 | }
110 |
111 | return mainContent;
112 | });
113 |
114 | return <>{renderContent()}>;
115 | };
116 |
117 | export default SettingsItem;
118 |
--------------------------------------------------------------------------------
/src/components/Settings/Item/index.ts:
--------------------------------------------------------------------------------
1 | import SettingsItem from "./SettingsItem";
2 |
3 | export default SettingsItem;
4 |
--------------------------------------------------------------------------------
/src/components/Settings/LockScreenWallpaperSwitch.tsx:
--------------------------------------------------------------------------------
1 | import { LazySwitch } from "~/lazy";
2 | import SettingsItem from "./Item";
3 |
4 | import { writeConfigFile } from "~/commands";
5 |
6 | import { useConfig, useTranslations } from "~/contexts";
7 |
8 | const LockScreenWallpaperSwitch = () => {
9 | const { data: config, refetch: refetchConfig } = useConfig();
10 | const { translate } = useTranslations();
11 |
12 | const onSwitchLockScreenWallpaper = async () => {
13 | await writeConfigFile({
14 | ...config()!,
15 | lock_screen_wallpaper_enabled: !config()!.lock_screen_wallpaper_enabled,
16 | });
17 |
18 | refetchConfig();
19 | };
20 |
21 | return (
22 |
26 |
30 |
31 | );
32 | };
33 |
34 | export default LockScreenWallpaperSwitch;
35 |
--------------------------------------------------------------------------------
/src/components/Settings/ThemesDirectory.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal } from "solid-js";
2 |
3 | import { confirm, message, open } from "@tauri-apps/plugin-dialog";
4 |
5 | import { LazyButton, LazySpace, LazyTooltip } from "~/lazy";
6 | import SettingsItem from "./Item";
7 |
8 | import { moveThemesDirectory, openDir } from "~/commands";
9 |
10 | import { useConfig, useTranslations } from "~/contexts";
11 |
12 | const ThemesDirectory = () => {
13 | const { data: config, refetch: refetchConfig } = useConfig();
14 | const { translate } = useTranslations();
15 |
16 | const [path, setPath] = createSignal(config()?.themes_directory);
17 |
18 | const onOpenThemesDirectory = () => {
19 | openDir(path()!);
20 | };
21 |
22 | const onChangePath = async () => {
23 | const dirPath = await open({ directory: true });
24 | if (!dirPath) return;
25 |
26 | const newThemesDirectory = `${dirPath}\\themes`;
27 |
28 | const ok = await confirm(
29 | translate("message-change-themes-directory", {
30 | newThemesDirectory,
31 | }),
32 | );
33 | if (!ok) return;
34 |
35 | try {
36 | await moveThemesDirectory(config()!, newThemesDirectory);
37 | message(
38 | translate("message-themes-directory-moved", {
39 | newThemesDirectory,
40 | }),
41 | );
42 | setPath(newThemesDirectory);
43 | refetchConfig();
44 | } catch (e) {
45 | message(String(e), { kind: "error" });
46 | }
47 | };
48 |
49 | return (
50 |
55 |
56 |
61 |
66 | {path()}
67 |
68 |
69 |
70 |
71 | {translate("button-select-folder")}
72 |
73 |
74 |
75 | );
76 | };
77 |
78 | export default ThemesDirectory;
79 |
--------------------------------------------------------------------------------
/src/components/Settings/index.tsx:
--------------------------------------------------------------------------------
1 | import { LazyFlex } from "~/lazy";
2 | import AutoStart from "./AutoStart";
3 | import AutoDetectColorMode from "./AutoDetectColorMode";
4 | import CoordinateSource from "./CoordinateSource";
5 | import Interval from "./Interval";
6 | import GithubMirror from "./GithubMirror";
7 | import ThemesDirectory from "./ThemesDirectory";
8 | import LockScreenWallpaperSwitch from "./LockScreenWallpaperSwitch";
9 | import SettingsFooter from "./Footer";
10 | import { appVars } from "~/themes/vars.css";
11 |
12 | const Settings = () => {
13 | return (
14 | <>
15 |
27 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | >
52 | );
53 | };
54 |
55 | export default Settings;
56 |
--------------------------------------------------------------------------------
/src/components/Sidebar/Sidebar.css.ts:
--------------------------------------------------------------------------------
1 | import { style } from "@vanilla-extract/css";
2 |
3 | export const sidebar = style({
4 | height: "560px",
5 | display: "flex",
6 | flexDirection: "column",
7 | });
8 |
--------------------------------------------------------------------------------
/src/components/Sidebar/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { themes } from "~/themes";
2 | import { LazyFlex } from "~/lazy";
3 | import ThemeMenu from "~/components/ThemeMenu";
4 | import SidebarButtons from "./SidebarButtons";
5 | import { sidebar } from "./Sidebar.css";
6 |
7 | const Sidebar = () => (
8 |
13 | );
14 |
15 | export default Sidebar;
16 |
--------------------------------------------------------------------------------
/src/components/Sidebar/SidebarButtons.css.ts:
--------------------------------------------------------------------------------
1 | import { style } from "@vanilla-extract/css";
2 |
3 | export const sidebarButtons = style({
4 | flex: 1,
5 | });
6 |
--------------------------------------------------------------------------------
/src/components/Sidebar/SidebarButtons.tsx:
--------------------------------------------------------------------------------
1 | import { Show } from "solid-js";
2 | import { AiFillSetting } from "solid-icons/ai";
3 | import { TbArrowBigUpLinesFilled } from "solid-icons/tb";
4 | import { LazyButton, LazySpace, LazyTooltip } from "~/lazy";
5 | import { useSettings, useTheme, useTranslations, useUpdate } from "~/contexts";
6 | import { sidebarButtons } from "./SidebarButtons.css";
7 |
8 | const SidebarButtons = () => {
9 | const { translate } = useTranslations();
10 | const { setMenuItemIndex, downloadThemeID } = useTheme();
11 | const { update: updateIsAvailable, setShowUpdateDialog } = useUpdate();
12 | const { setShowSettings } = useSettings();
13 |
14 | const onUpdate = () => {
15 | updateIsAvailable() && setShowUpdateDialog(true);
16 | };
17 |
18 | return (
19 |
55 | );
56 | };
57 |
58 | export default SidebarButtons;
59 |
--------------------------------------------------------------------------------
/src/components/Sidebar/index.ts:
--------------------------------------------------------------------------------
1 | import Sidebar from "./Sidebar";
2 |
3 | export default Sidebar;
4 |
--------------------------------------------------------------------------------
/src/components/ThemeActions.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal, Show } from "solid-js";
2 |
3 | import { LazyButton } from "~/lazy";
4 | import DangerButton from "./DangerButton";
5 |
6 | import { useMonitor, useTask, useTheme, useTranslations } from "~/contexts";
7 |
8 | export interface ThemeActionsProps {
9 | themeExists: boolean;
10 | appliedThemeID?: string;
11 | currentThemeID: string;
12 | onDownload: () => void;
13 | onApply: () => void;
14 | onCloseTask: () => void;
15 | downloadThemeID?: string;
16 | }
17 |
18 | export const ThemeActions = () => {
19 | const theme = useTheme();
20 | const { id: monitorID } = useMonitor();
21 |
22 | const { translate } = useTranslations();
23 | const { handleTaskClosure } = useTask();
24 |
25 | const [spinning, setSpinning] = createSignal(false);
26 |
27 | const onApply = async () => {
28 | setSpinning(true);
29 | await theme.handleThemeApplication(monitorID);
30 | setSpinning(false);
31 | };
32 |
33 | const onClose = () => {
34 | setSpinning(true);
35 | handleTaskClosure();
36 | setSpinning(false);
37 | };
38 |
39 | return (
40 |
44 | theme.setDownloadThemeID(theme.currentTheme()!.id)}
46 | disabled={!!theme.downloadThemeID()}
47 | >
48 | {translate("button-download")}
49 |
50 |
51 | }
52 | >
53 |
57 | {translate("button-stop")}
58 |
59 | }
60 | >
61 |
66 | {translate("button-apply")}
67 |
68 |
69 |
70 | );
71 | };
72 |
--------------------------------------------------------------------------------
/src/components/ThemeMenu/ThemeMenu.css.ts:
--------------------------------------------------------------------------------
1 | import { style, globalStyle, createVar } from "@vanilla-extract/css";
2 | import { themeContract, vars } from "fluent-solid/lib/themes";
3 |
4 | export const menuItemColorShadow = createVar();
5 |
6 | export const thumbnailsContainer = style({
7 | flex: "7",
8 | overflowY: "auto",
9 | padding: `${vars.spacingVerticalMNudge} ${vars.spacingHorizontalMNudge} ${vars.spacingVerticalMNudge} ${vars.spacingHorizontalXL}`,
10 | });
11 |
12 | export const menuItemDisabled = style({});
13 |
14 | export const menuItem = style({
15 | vars: {
16 | [menuItemColorShadow]: "rgba(0, 0, 0, 0.3)",
17 | },
18 | padding: "4px",
19 | borderRadius: "5px",
20 | height: "64px",
21 | width: "64px",
22 | display: "flex",
23 | alignItems: "center",
24 | position: "relative",
25 | transition: `all ${vars.durationNormal} ease-in-out`,
26 | backgroundColor: themeContract.colorNeutralBackground6,
27 |
28 | selectors: {
29 | "&::after": {
30 | content: '""',
31 | position: "absolute",
32 | top: 0,
33 | left: 0,
34 | right: 0,
35 | bottom: 0,
36 | opacity: 0,
37 | borderRadius: "5px",
38 | background:
39 | "linear-gradient(45deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))",
40 | transition: `opacity ${vars.durationNormal} ease-in-out`,
41 | pointerEvents: "none",
42 | },
43 |
44 | [`&:not(${menuItemDisabled}):hover`]: {
45 | background: themeContract.colorNeutralCardBackgroundHover,
46 | transform: "translateY(-2px)",
47 | boxShadow: `0 5px 15px ${menuItemColorShadow}`,
48 | },
49 |
50 | [`&:not(${menuItemDisabled}):hover::after`]: {
51 | opacity: 1,
52 | },
53 |
54 | [`&:not(${menuItemDisabled}):hover:active`]: {
55 | vars: {
56 | [menuItemColorShadow]: "rgba(0, 0, 0, 0.5)",
57 | },
58 | background: themeContract.colorNeutralCardBackgroundPressed,
59 | boxShadow: `0 3px 10px ${menuItemColorShadow}`,
60 | scale: "0.95",
61 | },
62 | },
63 | });
64 |
65 | export const menuItemActive = style({
66 | vars: {
67 | [menuItemColorShadow]: "rgba(0, 0, 0, 0.5)",
68 | },
69 | backgroundColor: themeContract.colorNeutralCardBackgroundHover,
70 | transform: "translateY(-2px)",
71 | boxShadow: `0 3px 10px ${menuItemColorShadow}`,
72 |
73 | selectors: {
74 | "&::after": {
75 | opacity: 1,
76 | },
77 | },
78 | });
79 |
80 | export const menuItemApplied = style({
81 | position: "relative",
82 | });
83 |
84 | export const menuItemAppliedBadge = style({
85 | position: "absolute",
86 | right: "4px",
87 | bottom: "8px",
88 | width: "16px",
89 | height: "16px",
90 | });
91 |
92 | globalStyle(`${menuItem} img`, {
93 | borderRadius: vars.borderRadiusMedium,
94 | });
95 |
--------------------------------------------------------------------------------
/src/components/ThemeMenu/ThemeMenu.tsx:
--------------------------------------------------------------------------------
1 | import { children, createMemo } from "solid-js";
2 | import { LazyBadge, LazyFlex, LazyTooltip } from "~/lazy";
3 | import Image from "../Image";
4 | import { BsCheckLg } from "solid-icons/bs";
5 | import { generateGitHubThumbnailMirrorUrl } from "~/utils/proxy";
6 | import { useConfig, useSettings, useTheme } from "~/contexts";
7 | import * as styles from "./ThemeMenu.css";
8 |
9 | interface ThemeMenuProps {
10 | themes: ThemeItem[];
11 | }
12 |
13 | const ThemeMenu = (props: ThemeMenuProps) => {
14 | const theme = useTheme();
15 | const settings = useSettings();
16 | const { data: config } = useConfig();
17 | const heights: Record = {};
18 |
19 | const disabled = createMemo(() => !!theme.downloadThemeID());
20 |
21 | const menu = children(() =>
22 | props.themes.map((item, idx) => (
23 | {
25 | if (disabled()) return; // Prevent theme switching while downloading
26 |
27 | theme.handleThemeSelection(idx);
28 | settings.setShowSettings(false);
29 | }}
30 | classList={{
31 | [styles.menuItem]: true,
32 | [styles.menuItemActive]: idx === theme.menuItemIndex(),
33 | [styles.menuItemApplied]: item.id === theme.appliedThemeID(),
34 | [styles.menuItemDisabled]: disabled(),
35 | }}
36 | >
37 |
38 | {
48 | heights[item.id] = height;
49 | }}
50 | />
51 |
52 | {item.id === theme.appliedThemeID() && (
53 |
61 | )}
62 |
63 | )),
64 | );
65 |
66 | return (
67 |
75 | {menu()}
76 |
77 | );
78 | };
79 |
80 | export default ThemeMenu;
81 |
--------------------------------------------------------------------------------
/src/components/ThemeMenu/index.ts:
--------------------------------------------------------------------------------
1 | import ThemeMenu from "./ThemeMenu";
2 |
3 | export default ThemeMenu;
4 |
--------------------------------------------------------------------------------
/src/components/ThemeShowcase.tsx:
--------------------------------------------------------------------------------
1 | import { Show } from "solid-js";
2 |
3 | import { LazyFlex } from "~/lazy";
4 | import Download from "./Download";
5 | import ImageCarousel from "./ImageCarousel";
6 | import { ThemeActions } from "./ThemeActions";
7 |
8 | import { useTheme } from "~/contexts";
9 |
10 | const ThemeShowcase = () => {
11 | const theme = useTheme();
12 |
13 | return (
14 |
21 |
22 |
23 |
24 | }>
25 |
26 |
27 |
28 |
29 | );
30 | };
31 |
32 | export default ThemeShowcase;
33 |
--------------------------------------------------------------------------------
/src/components/Update/UpdateDialog.tsx:
--------------------------------------------------------------------------------
1 | import { createEffect, createSignal, onMount } from "solid-js";
2 | import { AiOutlineDownload } from "solid-icons/ai";
3 |
4 | import type { Update } from "@tauri-apps/plugin-updater";
5 | import { message } from "@tauri-apps/plugin-dialog";
6 | import { open } from "@tauri-apps/plugin-shell";
7 |
8 | import { LazyButton, LazyProgress } from "~/lazy";
9 | import Dialog from "../Dialog";
10 |
11 | import { useToast, useTranslations, useUpdate } from "~/contexts";
12 |
13 | interface UpdateDialogProps {
14 | update: Update;
15 | }
16 |
17 | const UpdateDialog = (props: UpdateDialogProps) => {
18 | const toast = useToast();
19 | const { translate, translateErrorMessage } = useTranslations();
20 | const { setShowUpdateDialog } = useUpdate();
21 |
22 | const [total, setTotal] = createSignal();
23 | const [downloaded, setDownloaded] = createSignal();
24 | const [error, setError] = createSignal();
25 |
26 | onMount(async () => {
27 | try {
28 | await props.update.downloadAndInstall((event) => {
29 | switch (event.event) {
30 | case "Started":
31 | setTotal(event.data.contentLength ?? 0);
32 | break;
33 | case "Progress":
34 | setDownloaded((prev) => (prev ?? 0) + event.data.chunkLength);
35 | break;
36 | case "Finished":
37 | break;
38 | }
39 | });
40 | } catch (error) {
41 | const errorMessage = translateErrorMessage(
42 | "message-update-failed",
43 | error,
44 | );
45 | await message(errorMessage, {
46 | kind: "error",
47 | });
48 | setError(errorMessage);
49 | setShowUpdateDialog(false);
50 | }
51 | });
52 |
53 | const updateErrorHelpMessage = (message: string) => {
54 | return (
55 |
56 |
{message}
57 |
58 | {translate("help-update-failed")}
59 |
61 | open(
62 | (
63 | props.update.rawJson.platforms as Record<
64 | string,
65 | Record
66 | >
67 | )["windows-x86_64"].url,
68 | )
69 | }
70 | icon={}
71 | appearance="primary"
72 | size="small"
73 | />
74 |
75 |
76 | );
77 | };
78 |
79 | createEffect(() => {
80 | error() &&
81 | toast.error(updateErrorHelpMessage(error()!), {
82 | position: "bottom-right",
83 | duration: 5000,
84 | });
85 | });
86 |
87 | return (
88 |
100 | );
101 | };
102 |
103 | export default UpdateDialog;
104 |
--------------------------------------------------------------------------------
/src/components/Update/index.tsx:
--------------------------------------------------------------------------------
1 | import { Show } from "solid-js";
2 | import UpdateDialog from "./UpdateDialog";
3 | import { useUpdate } from "~/contexts";
4 |
5 | const Updater = () => {
6 | const { update, showUpdateDialog } = useUpdate();
7 |
8 | return (
9 |
10 |
11 |
12 | );
13 | };
14 |
15 | export default Updater;
16 |
--------------------------------------------------------------------------------
/src/contexts/ConfigContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, type ParentProps } from "solid-js";
2 |
3 | import { useConfigState } from "~/hooks/state";
4 |
5 | interface ConfigContext {
6 | data: Resource;
7 | refetch: () => Config | Promise | null | undefined;
8 | mutate: Setter;
9 | }
10 |
11 | const ConfigContext = createContext();
12 |
13 | export const ConfigProvider = (props: ParentProps) => {
14 | const config = useConfigState();
15 |
16 | return (
17 |
18 | {props.children}
19 |
20 | );
21 | };
22 |
23 | export const useConfig = () => {
24 | const context = useContext(ConfigContext);
25 | if (!context) {
26 | throw new Error("useConfig: must be used within a ConfigProvider");
27 | }
28 | return context;
29 | };
30 |
--------------------------------------------------------------------------------
/src/contexts/MonitorContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, type ParentProps, useContext } from "solid-js";
2 | import { useMonitorSelection, useMonitorThemeSync } from "~/hooks/monitor";
3 |
4 | interface MonitorContext {
5 | id: Accessor;
6 | setId: Setter;
7 | list: Accessor;
8 | specificThemes: Accessor<[string, string][]>;
9 | allSameTheme: Accessor;
10 | handleChange: (value: string) => void;
11 | }
12 |
13 | const MonitorContext = createContext();
14 |
15 | export const MonitorProvider = (props: ParentProps) => {
16 | const {
17 | monitorID,
18 | setMonitorID,
19 | monitors,
20 | monitorSpecificThemes,
21 | monitorSpecificThemesIsSame,
22 | handleMonitorChange,
23 | } = useMonitorSelection();
24 |
25 | useMonitorThemeSync(monitorID, monitorSpecificThemesIsSame);
26 |
27 | return (
28 |
38 | {props.children}
39 |
40 | );
41 | };
42 |
43 | export const useMonitor = () => {
44 | const context = useContext(MonitorContext);
45 | if (!context) {
46 | throw new Error("useMonitor: must be used within a MonitorProvider");
47 | }
48 | return context;
49 | };
50 |
--------------------------------------------------------------------------------
/src/contexts/SettingsContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, type ParentProps, useContext } from "solid-js";
2 | import { useSettingsState } from "~/hooks/state/useSettingsState";
3 |
4 | interface SettingsContext {
5 | showSettings: Accessor;
6 | setShowSettings: Setter;
7 | }
8 |
9 | const SettingsContext = createContext();
10 |
11 | export const SettingsProvider = (props: ParentProps) => {
12 | const settings = useSettingsState();
13 |
14 | return (
15 |
16 | {props.children}
17 |
18 | );
19 | };
20 |
21 | export const useSettings = () => {
22 | const context = useContext(SettingsContext);
23 | if (!context) {
24 | throw new Error("useSettings: must be used within a SettingsProvider");
25 | }
26 | return context;
27 | };
28 |
--------------------------------------------------------------------------------
/src/contexts/TaskContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, type ParentProps, useContext } from "solid-js";
2 | import { useTaskManager } from "~/hooks/useTaskManager";
3 |
4 | interface TaskContext {
5 | handleTaskClosure: () => Promise;
6 | }
7 |
8 | const TaskContext = createContext();
9 |
10 | export const TaskProvider = (props: ParentProps) => {
11 | const task = useTaskManager();
12 |
13 | return (
14 | {props.children}
15 | );
16 | };
17 |
18 | export const useTask = () => {
19 | const context = useContext(TaskContext);
20 | if (!context) {
21 | throw new Error("useTask: must be used within a TaskProvider");
22 | }
23 | return context;
24 | };
25 |
--------------------------------------------------------------------------------
/src/contexts/ThemeContext.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext,
3 | createMemo,
4 | type ParentProps,
5 | useContext,
6 | } from "solid-js";
7 | import { checkThemeExists } from "~/commands";
8 | import { useThemeApplication } from "~/hooks/theme/useThemeApplication";
9 | import { useThemeState } from "~/hooks/theme/useThemeState";
10 | import { useLocationPermission } from "~/hooks/useLocationPermission";
11 | import { themes } from "~/themes";
12 | import { useConfig } from "./ConfigContext";
13 |
14 | interface ThemeContext {
15 | currentTheme: Accessor;
16 | appliedThemeID: Accessor;
17 | setAppliedThemeID: Setter;
18 | downloadThemeID: Accessor;
19 | setDownloadThemeID: Setter;
20 | menuItemIndex: Accessor;
21 | setMenuItemIndex: Setter;
22 | themeExists: Accessor;
23 | handleThemeSelection: (index: number) => void;
24 | handleThemeApplication: (monitorID: Accessor) => Promise;
25 | }
26 |
27 | const ThemeContext = createContext();
28 |
29 | export const ThemeProvider = (props: ParentProps) => {
30 | const { data: config, mutate, refetch: refetchConfig } = useConfig();
31 | const themeState = useThemeState();
32 | const {
33 | appliedThemeID,
34 | setAppliedThemeID,
35 | downloadThemeID,
36 | setDownloadThemeID,
37 | menuItemIndex,
38 | setMenuItemIndex,
39 | themeExists,
40 | setThemeExists,
41 | setShowSettings,
42 | } = themeState;
43 |
44 | const currentTheme = createMemo(() => {
45 | const idx = menuItemIndex();
46 | if (idx === undefined) return;
47 | return themes[idx];
48 | });
49 |
50 | // Handle theme selection
51 | const handleThemeSelection = async (idx: number) => {
52 | setMenuItemIndex(idx);
53 | try {
54 | await checkThemeExists(config()?.themes_directory ?? "", themes[idx].id);
55 | setThemeExists(true);
56 | } catch (e) {
57 | setThemeExists(false);
58 | console.error(`Failed to check theme existence: index=${idx} error=${e}`);
59 | }
60 | };
61 |
62 | // Use location permission Hook
63 | const { checkLocationPermission } = useLocationPermission(
64 | mutate,
65 | setShowSettings,
66 | );
67 |
68 | // Use theme application Hook
69 | const { handleThemeApplication } = useThemeApplication(
70 | config,
71 | refetchConfig,
72 | currentTheme,
73 | checkLocationPermission,
74 | setAppliedThemeID,
75 | );
76 |
77 | return (
78 |
92 | {props.children}
93 |
94 | );
95 | };
96 |
97 | export const useTheme = () => {
98 | const context = useContext(ThemeContext);
99 | if (!context) {
100 | throw new Error("useTheme: must be used within a ThemeProvider");
101 | }
102 | return context;
103 | };
104 |
--------------------------------------------------------------------------------
/src/contexts/TranslationsContext.tsx:
--------------------------------------------------------------------------------
1 | import type { JSX } from "solid-js";
2 | import { createContext, useContext, createResource } from "solid-js";
3 | import { getTranslations } from "~/commands";
4 | import {
5 | translate as translateFn,
6 | translateErrorMessage as translateErrorMessageFn,
7 | } from "~/utils/i18n";
8 |
9 | type TranslationsContextType = {
10 | translations: () => Translations | undefined;
11 | translate: (key: TranslationKey, params?: Record) => string;
12 | translateErrorMessage: (
13 | key: TranslationKey,
14 | error: unknown,
15 | params?: Record,
16 | ) => string;
17 | };
18 |
19 | const TranslationsContext = createContext();
20 |
21 | export const TranslationsProvider = (props: { children: JSX.Element }) => {
22 | const [translations] = createResource(getTranslations);
23 |
24 | const translate = (
25 | key: TranslationKey,
26 | params: Record = {},
27 | ) => {
28 | if (!translations()) return key;
29 | return translateFn(translations()!, key, params);
30 | };
31 |
32 | const translateErrorMessage = (
33 | key: TranslationKey,
34 | error: unknown,
35 | params: Record = {},
36 | ) => {
37 | if (!translations()) return key;
38 | return translateErrorMessageFn(translations()!, key, error, params);
39 | };
40 |
41 | const value = {
42 | translations,
43 | translate,
44 | translateErrorMessage,
45 | };
46 |
47 | return (
48 |
49 | {props.children}
50 |
51 | );
52 | };
53 |
54 | export const useTranslations = () => {
55 | const context = useContext(TranslationsContext);
56 | if (!context) {
57 | throw new Error("useTranslations: cannot find a TranslationsContext");
58 | }
59 | return context;
60 | };
61 |
--------------------------------------------------------------------------------
/src/contexts/UpdateContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, type ParentProps, useContext } from "solid-js";
2 | import { useUpdateManager } from "~/hooks/useUpdateManager";
3 |
4 | interface UpdateContext {
5 | showUpdateDialog: Accessor;
6 | setShowUpdateDialog: Setter;
7 | update: Resource;
8 | recheckUpdate: Refetcher;
9 | }
10 |
11 | const UpdateContext = createContext();
12 |
13 | export const UpdateProvider = (props: ParentProps) => {
14 | const update = useUpdateManager();
15 |
16 | return (
17 |
18 | {props.children}
19 |
20 | );
21 | };
22 |
23 | export const useUpdate = () => {
24 | const context = useContext(UpdateContext);
25 | if (!context) {
26 | throw new Error("useUpdate: must be used within an UpdateProvider");
27 | }
28 | return context;
29 | };
30 |
--------------------------------------------------------------------------------
/src/contexts/index.tsx:
--------------------------------------------------------------------------------
1 | import type { ParentProps } from "solid-js";
2 |
3 | import { TranslationsProvider } from "./TranslationsContext";
4 | import { ConfigProvider } from "./ConfigContext";
5 | import { ThemeProvider } from "./ThemeContext";
6 | import { MonitorProvider } from "./MonitorContext";
7 | import { UpdateProvider } from "./UpdateContext";
8 | import { SettingsProvider } from "./SettingsContext";
9 | import { TaskProvider } from "./TaskContext";
10 | import { ToastProvider } from "fluent-solid/lib/index";
11 |
12 | export const AppProvider = (props: ParentProps) => {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {props.children}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | };
31 |
32 | export { useTranslations } from "./TranslationsContext";
33 | export { useConfig } from "./ConfigContext";
34 | export { useTheme } from "./ThemeContext";
35 | export { useMonitor } from "./MonitorContext";
36 | export { useSettings } from "./SettingsContext";
37 | export { useUpdate } from "./UpdateContext";
38 | export { useTask } from "./TaskContext";
39 | export { useToast } from "fluent-solid";
40 |
--------------------------------------------------------------------------------
/src/hooks/__tests__/usePausableTimeout.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, vi, beforeEach } from "vitest";
2 | import { usePausableTimeout } from "../usePausableTimeout";
3 |
4 | describe("usePausableTimeout", () => {
5 | beforeEach(() => {
6 | vi.useFakeTimers();
7 | });
8 |
9 | it("should execute timeout callback normally", () => {
10 | const callback = vi.fn();
11 | const { start } = usePausableTimeout(callback, 1000);
12 |
13 | start();
14 | vi.advanceTimersByTime(1000);
15 | expect(callback).toHaveBeenCalled();
16 | });
17 |
18 | it("should pause and resume correctly", () => {
19 | const callback = vi.fn();
20 | const { start, pause, resume } = usePausableTimeout(callback, 1000);
21 |
22 | start();
23 | vi.advanceTimersByTime(500);
24 | pause();
25 | vi.advanceTimersByTime(1000);
26 | expect(callback).not.toHaveBeenCalled();
27 |
28 | resume();
29 | vi.advanceTimersByTime(500);
30 | expect(callback).toHaveBeenCalled();
31 | });
32 |
33 | it("remaining time should not be less than 0", () => {
34 | const callback = vi.fn();
35 | const { start, pause, resume } = usePausableTimeout(callback, 500);
36 |
37 | start();
38 | vi.advanceTimersByTime(300);
39 | pause();
40 | vi.advanceTimersByTime(300);
41 | resume();
42 |
43 | vi.advanceTimersByTime(200);
44 | expect(callback).toHaveBeenCalled();
45 | });
46 |
47 | it("should execute callback immediately when resuming with remaining time of 0", () => {
48 | const callback = vi.fn();
49 | const { start, pause, resume } = usePausableTimeout(callback, 500);
50 |
51 | start();
52 | vi.advanceTimersByTime(500);
53 | pause();
54 |
55 | resume();
56 | expect(callback).toHaveBeenCalled();
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/src/hooks/monitor/index.ts:
--------------------------------------------------------------------------------
1 | export { useMonitorSelection } from "./useMonitorSelection";
2 | export { useMonitorThemeSync } from "./useMonitorThemeSync";
3 |
--------------------------------------------------------------------------------
/src/hooks/monitor/useMonitorSelection.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createSignal,
3 | createMemo,
4 | createResource,
5 | createEffect,
6 | } from "solid-js";
7 | import { getMonitors } from "~/commands";
8 | import { useConfig } from "~/contexts";
9 | import { isSubset } from "~/utils/array";
10 |
11 | export const useMonitorSelection = () => {
12 | const { data: config } = useConfig();
13 |
14 | const [monitorInfoObject] = createResource(getMonitors);
15 | const [monitorID, setMonitorID] = createSignal("all");
16 |
17 | const originalMonitors = createMemo(() => {
18 | const monitorIDs = Object.keys(monitorInfoObject() ?? {}).sort();
19 |
20 | return monitorIDs.map((id) => ({
21 | value: monitorInfoObject()?.[id].device_path || "",
22 | label: monitorInfoObject()?.[id].friendly_name || "",
23 | }));
24 | });
25 |
26 | // Get monitor list
27 | const monitors = createMemo(() => [
28 | { value: "all", label: "All" },
29 | ...originalMonitors(),
30 | ]);
31 |
32 | // Get monitor-specific theme configuration
33 | const monitorSpecificThemes = createMemo(() => {
34 | const monitor_specific_wallpapers = config()?.monitor_specific_wallpapers;
35 | if (!monitor_specific_wallpapers) return [];
36 |
37 | if (typeof monitor_specific_wallpapers === "string") {
38 | const monitorIDs = Object.keys(monitorInfoObject() ?? {}).sort();
39 | return monitorIDs.map(
40 | (id) => [id, monitor_specific_wallpapers] as [string, string],
41 | );
42 | }
43 |
44 | return Object.entries(monitor_specific_wallpapers).sort((a, b) =>
45 | a[0].toLocaleLowerCase().localeCompare(b[0]),
46 | );
47 | });
48 |
49 | // Check if all monitors are using the same theme
50 | const monitorSpecificThemesIsSame = createMemo(() => {
51 | const monitor_specific_wallpapers = config()?.monitor_specific_wallpapers;
52 | if (
53 | !monitor_specific_wallpapers ||
54 | typeof monitor_specific_wallpapers === "string"
55 | )
56 | return true;
57 |
58 | const themes = monitorSpecificThemes();
59 | const monitorIDs = Object.keys(monitorInfoObject() ?? {}).sort();
60 |
61 | if (
62 | !isSubset(
63 | monitorIDs,
64 | themes.map((i) => i[0]),
65 | )
66 | )
67 | return false;
68 |
69 | return themes.every((value) => value[1] === themes[0][1]);
70 | });
71 |
72 | // Initialize monitor selection based on configuration
73 | createEffect(() => {
74 | if (!config()?.monitor_specific_wallpapers) return;
75 |
76 | const selectValue = monitorSpecificThemesIsSame()
77 | ? "all"
78 | : (monitorSpecificThemes()[0]?.[0] ?? "all");
79 | setMonitorID(selectValue);
80 | });
81 |
82 | // Handle monitor change
83 | const handleMonitorChange = (value: string) => {
84 | setMonitorID(value);
85 | };
86 |
87 | return {
88 | monitorID,
89 | setMonitorID,
90 | monitors,
91 | monitorSpecificThemes,
92 | monitorSpecificThemesIsSame,
93 | handleMonitorChange,
94 | };
95 | };
96 |
--------------------------------------------------------------------------------
/src/hooks/monitor/useMonitorThemeSync.tsx:
--------------------------------------------------------------------------------
1 | import { createEffect } from "solid-js";
2 | import { useConfig, useTheme } from "~/contexts";
3 | import { themes } from "~/themes";
4 |
5 | /**
6 | * Hook for monitoring display selection changes and synchronizing theme state
7 | * @param themes List of available themes
8 | * @param monitorID Currently selected monitor ID
9 | * @param config Application configuration
10 | * @param monitorSpecificThemesIsSame Whether all monitors use the same theme
11 | * @param setAppliedThemeID Function to set the applied theme ID
12 | * @param setMenuItemIndex Function to set the current selected theme index
13 | * @returns No return value, only provides side effects
14 | */
15 | export const useMonitorThemeSync = (
16 | monitorID: () => string,
17 | monitorSpecificThemesIsSame: () => boolean,
18 | ) => {
19 | const { data: config } = useConfig();
20 | const { setAppliedThemeID, setMenuItemIndex } = useTheme();
21 |
22 | // Monitor display selection changes, update theme state
23 | createEffect(async () => {
24 | const id = getThemeID(monitorID(), config()?.monitor_specific_wallpapers);
25 |
26 | if (!id || (!monitorSpecificThemesIsSame() && monitorID() === "all")) {
27 | setAppliedThemeID(undefined);
28 | setMenuItemIndex(0);
29 | } else {
30 | const index = themes.findIndex((t) => t.id === id);
31 | setAppliedThemeID(id);
32 | setMenuItemIndex(index);
33 | }
34 | });
35 | };
36 |
37 | const getThemeID = (
38 | monitorID: string,
39 | monitor_specific_wallpapers?: Config["monitor_specific_wallpapers"],
40 | ) => {
41 | if (typeof monitor_specific_wallpapers === "string")
42 | return monitor_specific_wallpapers;
43 |
44 | return monitor_specific_wallpapers?.[monitorID];
45 | };
46 |
--------------------------------------------------------------------------------
/src/hooks/state/index.tsx:
--------------------------------------------------------------------------------
1 | export { useConfigState } from "./useConfigState";
2 |
--------------------------------------------------------------------------------
/src/hooks/state/useConfigState.tsx:
--------------------------------------------------------------------------------
1 | import { createResource } from "solid-js";
2 |
3 | import { readConfigFile } from "~/commands";
4 |
5 | export const useConfigState = () => {
6 | const [data, { refetch, mutate }] = createResource(readConfigFile);
7 |
8 | return {
9 | data,
10 | refetch,
11 | mutate,
12 | };
13 | };
14 |
--------------------------------------------------------------------------------
/src/hooks/state/useSettingsState.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal } from "solid-js";
2 |
3 | export const useSettingsState = () => {
4 | const [showSettings, setShowSettings] = createSignal(false);
5 |
6 | return {
7 | showSettings,
8 | setShowSettings,
9 | };
10 | };
11 |
--------------------------------------------------------------------------------
/src/hooks/theme/useThemeApplication.tsx:
--------------------------------------------------------------------------------
1 | import { message } from "@tauri-apps/plugin-dialog";
2 | import { applyTheme } from "~/commands";
3 | import { useTranslations } from "~/contexts";
4 |
5 | /**
6 | * Theme application management Hook, used to handle theme application and task closure
7 | * @param config Application configuration
8 | * @param refetchConfig Function to refetch configuration
9 | * @param currentTheme Currently selected theme
10 | * @param checkLocationPermission Function to check location permission
11 | * @param setAppliedThemeID Function to set the applied theme ID
12 | * @returns Theme application related methods
13 | */
14 | export const useThemeApplication = (
15 | config: () => Config | undefined,
16 | refetchConfig: () => void,
17 | currentTheme: () => ThemeItem | undefined,
18 | checkLocationPermission: () => Promise,
19 | setAppliedThemeID: (id?: string) => void,
20 | ) => {
21 | const { translateErrorMessage } = useTranslations();
22 |
23 | // Handle theme application
24 | const handleThemeApplication = async (monitorID: Accessor) => {
25 | const theme = currentTheme();
26 | if (!theme || !config()) return;
27 |
28 | const currentConfig = config()!;
29 |
30 | // Check coordinate configuration
31 | const hasValidCoordinates =
32 | currentConfig.coordinate_source?.type === "MANUAL" &&
33 | typeof currentConfig.coordinate_source.latitude === "number" &&
34 | typeof currentConfig.coordinate_source.longitude === "number";
35 |
36 | if (!hasValidCoordinates) {
37 | const hasPermission = await checkLocationPermission();
38 | if (!hasPermission) return;
39 | }
40 |
41 | // Update monitor-specific wallpaper configuration
42 | let monitorSpecificWallpapers: Record | string;
43 | // const monitorSpecificWallpapers: Record = {
44 | // ...currentConfig.monitor_specific_wallpapers,
45 | // };
46 |
47 | if (monitorID() === "all") {
48 | // Set the same theme for all monitors
49 | monitorSpecificWallpapers = theme.id;
50 | } else {
51 | // Set theme for specific monitor
52 | monitorSpecificWallpapers =
53 | typeof currentConfig.monitor_specific_wallpapers === "string"
54 | ? {}
55 | : { ...currentConfig.monitor_specific_wallpapers };
56 |
57 | monitorSpecificWallpapers[monitorID()!] = theme.id;
58 | }
59 |
60 | currentConfig.monitor_specific_wallpapers = monitorSpecificWallpapers;
61 |
62 | try {
63 | await applyTheme(currentConfig);
64 | refetchConfig();
65 | setAppliedThemeID(theme.id);
66 | } catch (e) {
67 | message(translateErrorMessage("message-apply-theme-failed", e), {
68 | kind: "error",
69 | });
70 | }
71 | };
72 |
73 | return {
74 | handleThemeApplication,
75 | };
76 | };
77 |
--------------------------------------------------------------------------------
/src/hooks/theme/useThemeSelection.tsx:
--------------------------------------------------------------------------------
1 | import { createMemo } from "solid-js";
2 | import { checkThemeExists } from "~/commands";
3 |
4 | /**
5 | * Theme selection management Hook, used to handle theme selection related functions
6 | * @param themes List of available themes
7 | * @param config Application configuration
8 | * @param menuItemIndex Currently selected theme index
9 | * @param setMenuItemIndex Function to set the current selected theme index
10 | * @param setThemeExists Function to set whether the theme exists
11 | * @returns Theme selection related states and methods
12 | */
13 | export const useThemeSelection = (
14 | themes: ThemeItem[],
15 | config: () => Config | undefined,
16 | menuItemIndex: () => number | undefined,
17 | setMenuItemIndex: (index: number) => void,
18 | setThemeExists: (exists: boolean) => void,
19 | ) => {
20 | // Calculate current selected theme
21 | const currentTheme = createMemo(() => {
22 | const idx = menuItemIndex();
23 | if (idx === undefined) return;
24 | return themes[idx];
25 | });
26 |
27 | // Handle theme selection
28 | const handleThemeSelection = async (idx: number) => {
29 | setMenuItemIndex(idx);
30 | try {
31 | await checkThemeExists(config()?.themes_directory ?? "", themes[idx].id);
32 | setThemeExists(true);
33 | } catch (e) {
34 | setThemeExists(false);
35 | console.error("Failed to check theme existence:", e);
36 | }
37 | };
38 |
39 | return {
40 | currentTheme,
41 | handleThemeSelection,
42 | };
43 | };
44 |
--------------------------------------------------------------------------------
/src/hooks/theme/useThemeState.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal } from "solid-js";
2 |
3 | /**
4 | * Theme base state management Hook, used to manage theme-related base states
5 | * @returns Theme base states and related methods
6 | */
7 | export const useThemeState = () => {
8 | const [appliedThemeID, setAppliedThemeID] = createSignal();
9 | const [downloadThemeID, setDownloadThemeID] = createSignal();
10 | const [menuItemIndex, setMenuItemIndex] = createSignal(0);
11 | const [themeExists, setThemeExists] = createSignal(false);
12 | const [showSettings, setShowSettings] = createSignal(false);
13 |
14 | return {
15 | appliedThemeID,
16 | setAppliedThemeID,
17 | downloadThemeID,
18 | setDownloadThemeID,
19 | menuItemIndex,
20 | setMenuItemIndex,
21 | themeExists,
22 | setThemeExists,
23 | showSettings,
24 | setShowSettings,
25 | };
26 | };
27 |
--------------------------------------------------------------------------------
/src/hooks/useAppInitialization.tsx:
--------------------------------------------------------------------------------
1 | import { open } from "@tauri-apps/plugin-shell";
2 | import { createEffect, onMount } from "solid-js";
3 | import { toastMessageLinkLikeButton } from "~/App.css";
4 | import {
5 | getAppliedThemeID,
6 | setTitlebarColorMode,
7 | showWindow,
8 | } from "~/commands";
9 | import { useMonitor, useTheme, useToast, useUpdate } from "~/contexts";
10 | import { themes } from "~/themes";
11 | import { detectColorMode } from "~/utils/color";
12 |
13 | /**
14 | * App initialization Hook, used to handle application startup logic
15 | * @param menuItemIndex Current menu item index
16 | * @param handleThemeSelection Function to handle theme selection
17 | */
18 | export const useAppInitialization = (
19 | translate: (key: TranslationKey, params?: Record) => string,
20 | menuItemIndex: Accessor,
21 | handleThemeSelection: (index: number) => void,
22 | ) => {
23 | const { setMenuItemIndex, setAppliedThemeID } = useTheme();
24 | const { id: monitorID } = useMonitor();
25 | const toast = useToast();
26 | const { update } = useUpdate();
27 |
28 | const openGithubRepository = async () => {
29 | await open("https://github.com/dwall-rs/dwall");
30 | };
31 |
32 | const githubStarMessage = (
33 |
34 | {translate("message-github-star")}
35 |
42 |
43 | );
44 |
45 | const updateMessage = (
46 |
47 | {translate("message-update-available", {
48 | version: update()?.version ?? "",
49 | currentVersion: update()?.currentVersion ?? "",
50 | })}
51 |
52 | );
53 |
54 | createEffect(() => {
55 | if (update()) {
56 | toast.info(updateMessage, {
57 | position: "top-right",
58 | });
59 | }
60 | });
61 |
62 | onMount(async () => {
63 | await setTitlebarColorMode(detectColorMode());
64 |
65 | if (import.meta.env.PROD) await showWindow("main");
66 |
67 | const mii = menuItemIndex();
68 | if (mii !== undefined) handleThemeSelection(mii);
69 |
70 | toast.info(githubStarMessage, {
71 | position: "top-right",
72 | duration: 5000,
73 | });
74 |
75 | const applied_theme_id = await getAppliedThemeID(monitorID());
76 | if (applied_theme_id) {
77 | const themeIndex = themes.findIndex((t) => t.id === applied_theme_id);
78 | if (themeIndex !== -1) {
79 | setMenuItemIndex(themeIndex);
80 | handleThemeSelection(themeIndex);
81 | setAppliedThemeID(applied_theme_id);
82 | return;
83 | }
84 | }
85 | });
86 | };
87 |
--------------------------------------------------------------------------------
/src/hooks/useColorMode.tsx:
--------------------------------------------------------------------------------
1 | import { onMount } from "solid-js";
2 | import { setTitlebarColorMode } from "~/commands";
3 |
4 | /**
5 | * System color mode management Hook, used to monitor system color mode changes and update the title bar
6 | */
7 | export const useColorMode = () => {
8 | onMount(() => {
9 | const darkModeMediaQuery = window.matchMedia(
10 | "(prefers-color-scheme: dark)",
11 | );
12 |
13 | const handleColorSchemeChange = (event: MediaQueryListEvent) => {
14 | setTitlebarColorMode(event.matches ? "DARK" : "LIGHT");
15 | };
16 |
17 | darkModeMediaQuery.addEventListener("change", handleColorSchemeChange);
18 | return () =>
19 | darkModeMediaQuery.removeEventListener("change", handleColorSchemeChange);
20 | });
21 | };
22 |
--------------------------------------------------------------------------------
/src/hooks/useDark.ts:
--------------------------------------------------------------------------------
1 | import { createSignal, createEffect, onMount } from "solid-js";
2 | import type { Accessor, Setter } from "solid-js";
3 |
4 | type DarkMode = "dark" | "light" | "system";
5 |
6 | /**
7 | * A custom hook that manages the dark mode state based on user preference or system setting.
8 | * It returns an accessor to the current dark mode state and a setter to change it.
9 | *
10 | * @param {DarkMode} [mode="system"] - The initial mode to start with, either 'dark', 'light', or 'system'.
11 | * @returns {[Accessor, Setter]} A tuple where the first element is an accessor that returns whether dark mode is active,
12 | * and the second element is a setter that can be used to manually toggle dark mode.
13 | */
14 | const useDark = (
15 | mode: DarkMode = "system",
16 | ): [Accessor, Setter] => {
17 | const [isDark, setIsDark] = createSignal(mode === "dark");
18 |
19 | onMount(() => {
20 | if (mode !== "system") return;
21 |
22 | if (matchMedia("(prefers-color-scheme: dark)").matches) {
23 | setIsDark(true);
24 | } else {
25 | setIsDark(false);
26 | }
27 |
28 | window
29 | .matchMedia("(prefers-color-scheme: dark)")
30 | .addEventListener("change", (event) => {
31 | if (event.matches) {
32 | setIsDark(true);
33 | } else {
34 | setIsDark(false);
35 | }
36 | });
37 | });
38 |
39 | createEffect(() => {
40 | if (isDark()) window.document.documentElement.setAttribute("class", "dark");
41 | else window.document.documentElement.removeAttribute("class");
42 | });
43 |
44 | return [isDark, setIsDark];
45 | };
46 |
47 | export default useDark;
--------------------------------------------------------------------------------
/src/hooks/useLocationPermission.tsx:
--------------------------------------------------------------------------------
1 | import { requestLocationPermission } from "~/commands";
2 |
3 | import { ask } from "@tauri-apps/plugin-dialog";
4 | import { exit } from "@tauri-apps/plugin-process";
5 |
6 | import { useTranslations } from "~/contexts";
7 |
8 | /**
9 | * Location permission management Hook, used to handle location permission requests and related operations
10 | * @param mutate Configuration update function
11 | * @param setShowSettings Function to set the display of settings panel
12 | * @returns Location permission related methods
13 | */
14 | export const useLocationPermission = (
15 | mutate: (fn: (prev: Config) => Config) => void,
16 | setShowSettings: (show: boolean) => void,
17 | ) => {
18 | const { translate } = useTranslations();
19 | // Check location permission
20 | const checkLocationPermission = async (): Promise => {
21 | try {
22 | await requestLocationPermission();
23 | return true;
24 | } catch (e) {
25 | const shouldContinue = await ask(
26 | translate("message-location-permission"),
27 | { kind: "warning" },
28 | );
29 |
30 | if (!shouldContinue) {
31 | exit(0);
32 | return false;
33 | }
34 |
35 | mutate((prev) => ({
36 | ...prev!,
37 | coordinate_source: { type: "MANUAL" },
38 | }));
39 | setShowSettings(true);
40 | return false;
41 | }
42 | };
43 |
44 | return { checkLocationPermission };
45 | };
46 |
--------------------------------------------------------------------------------
/src/hooks/usePausableTimeout.ts:
--------------------------------------------------------------------------------
1 | import { createSignal } from "solid-js";
2 |
3 | type TimeoutID = ReturnType;
4 |
5 | /**
6 | * A custom hook that provides pausable timeout functionality
7 | * @param callback The function to be executed after the timeout
8 | * @param delay The delay in milliseconds
9 | * @returns An array containing the start, pause, resume, and clear functions
10 | */
11 | export function usePausableTimeout(callback: () => void, delay: number) {
12 | const [remainingTime, setRemainingTime] = createSignal(null);
13 | const [timeoutId, setTimeoutId] = createSignal(null);
14 | const [startTime, setStartTime] = createSignal(null);
15 | const [isPaused, setIsPaused] = createSignal(false);
16 |
17 | const clear = () => {
18 | if (timeoutId()) {
19 | clearTimeout(timeoutId()!);
20 | setTimeoutId(null);
21 | }
22 | setRemainingTime(null);
23 | setStartTime(null);
24 | setIsPaused(false);
25 | };
26 |
27 | const start = () => {
28 | clear();
29 | setRemainingTime(delay);
30 | setStartTime(performance.now());
31 | setIsPaused(false);
32 | const id = setTimeout(() => {
33 | callback();
34 | clear();
35 | }, delay);
36 | setTimeoutId(id);
37 | };
38 |
39 | const pause = () => {
40 | if (!timeoutId() || isPaused()) return;
41 |
42 | clearTimeout(timeoutId()!);
43 | setTimeoutId(null);
44 | setIsPaused(true);
45 |
46 | const elapsedTime = performance.now() - (startTime() || 0);
47 | setRemainingTime((prev) => Math.max((prev || 0) - elapsedTime, 0));
48 | };
49 |
50 | const resume = () => {
51 | if (!isPaused() || !remainingTime()) return;
52 |
53 | setStartTime(performance.now());
54 | setIsPaused(false);
55 | const remaining = remainingTime();
56 | if (!remaining || remaining <= 0) {
57 | callback();
58 | return clear();
59 | }
60 |
61 | const id = setTimeout(() => {
62 | callback();
63 | clear();
64 | }, remaining);
65 | setTimeoutId(id);
66 | };
67 |
68 | return { start, pause, resume, clear } as const;
69 | }
70 |
--------------------------------------------------------------------------------
/src/hooks/useTaskManager.tsx:
--------------------------------------------------------------------------------
1 | import { applyTheme } from "~/commands";
2 | import { useConfig, useMonitor, useTheme } from "~/contexts";
3 |
4 | export const useTaskManager = () => {
5 | const { data: config, refetch: refetchConfig } = useConfig();
6 | const { setAppliedThemeID } = useTheme();
7 | const { id: monitorID } = useMonitor();
8 |
9 | const handleTaskClosure = async () => {
10 | if (!config()) return;
11 |
12 | const monitor_specific_wallpapers = {
13 | ...config()?.monitor_specific_wallpapers,
14 | };
15 |
16 | if (monitorID() === "all") {
17 | for (const monitorID in monitor_specific_wallpapers) {
18 | delete monitor_specific_wallpapers[monitorID];
19 | }
20 | } else {
21 | delete monitor_specific_wallpapers[monitorID()!];
22 | }
23 |
24 | const updatedConfig: Config = {
25 | ...config()!,
26 | selected_theme_id: undefined,
27 | monitor_specific_wallpapers,
28 | };
29 |
30 | try {
31 | await applyTheme(updatedConfig);
32 | refetchConfig();
33 | setAppliedThemeID(undefined);
34 | } catch (e) {
35 | console.error("Failed to close task:", e);
36 | }
37 | };
38 |
39 | return {
40 | handleTaskClosure,
41 | };
42 | };
43 |
--------------------------------------------------------------------------------
/src/hooks/useUpdateManager.tsx:
--------------------------------------------------------------------------------
1 | import { createResource, createSignal } from "solid-js";
2 | import { message } from "@tauri-apps/plugin-dialog";
3 | import { check } from "@tauri-apps/plugin-updater";
4 | import { useTranslations } from "~/contexts";
5 |
6 | export const useUpdateManager = () => {
7 | const { translateErrorMessage } = useTranslations();
8 |
9 | const [showUpdateDialog, setShowUpdateDialog] = createSignal();
10 | const [update, { refetch: recheckUpdate }] = createResource(async () => {
11 | try {
12 | return await check();
13 | } catch (e) {
14 | console.error(e);
15 | message(translateErrorMessage("message-update-failed", e), {
16 | kind: "error",
17 | });
18 | return;
19 | }
20 | });
21 |
22 | return {
23 | showUpdateDialog,
24 | setShowUpdateDialog,
25 | update,
26 | recheckUpdate,
27 | };
28 | };
29 |
--------------------------------------------------------------------------------
/src/index.css.ts:
--------------------------------------------------------------------------------
1 | import { globalStyle, createGlobalTheme } from "@vanilla-extract/css";
2 |
3 | export const vars = createGlobalTheme(":root", {
4 | colors: {
5 | scrollbar: "rgba(0, 0, 0, 0.1)",
6 | scrollbarHover: "rgba(0, 0, 0, 0.4)",
7 | },
8 | });
9 |
10 | globalStyle("*", {
11 | userSelect: "none",
12 | // @ts-ignore
13 | WebkitUserDrag: "none",
14 | MozUserSelect: "none",
15 | WebkitUserSelect: "none",
16 | msUserSelect: "none",
17 | });
18 |
19 | globalStyle("::-webkit-scrollbar", {
20 | width: "6px",
21 | height: "8px",
22 | });
23 |
24 | globalStyle("::-webkit-scrollbar-thumb", {
25 | borderRadius: "10px",
26 | background: vars.colors.scrollbar,
27 | });
28 |
29 | globalStyle("::-webkit-scrollbar-thumb:hover", {
30 | background: vars.colors.scrollbarHover,
31 | });
32 |
33 | globalStyle("::-webkit-scrollbar-track", {
34 | backgroundColor: "#fff0",
35 | });
36 |
37 | globalStyle("body", {
38 | overflow: "hidden",
39 | margin: 0,
40 | });
41 |
42 | globalStyle("#root", {
43 | height: "100vh",
44 | width: "100vw",
45 | display: "flex",
46 | flexDirection: "column",
47 | overflow: "hidden",
48 | });
49 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | /* @refresh reload */
2 | import { render } from "solid-js/web";
3 | import App from "./App";
4 | import { AppProvider } from "~/contexts";
5 | import "./index.css";
6 |
7 | if (import.meta.env.MODE === "production") {
8 | document.addEventListener("contextmenu", (event) => event.preventDefault());
9 | }
10 |
11 | render(
12 | () => (
13 |
14 |
15 |
16 | ),
17 | document.getElementById("root") as HTMLElement,
18 | );
19 |
--------------------------------------------------------------------------------
/src/lazy.tsx:
--------------------------------------------------------------------------------
1 | import type { FlexProps } from "~/components/Flex";
2 | import { lazy } from "solid-js";
3 |
4 | export const LazyFlex = lazy(() => import("~/components/Flex"));
5 |
6 | export const LazySpace = (props: Omit) => {
7 | return (
8 |
9 | {props.children}
10 |
11 | );
12 | };
13 |
14 | export const LazyButton = lazy(
15 | () => import("fluent-solid/lib/components/button/Button"),
16 | );
17 |
18 | export const LazyProgress = lazy(
19 | () => import("fluent-solid/lib/components/progress/Progress"),
20 | );
21 |
22 | export const LazyLabel = lazy(
23 | () => import("fluent-solid/lib/components/label/Label"),
24 | );
25 |
26 | export const LazySwitch = lazy(
27 | () => import("fluent-solid/lib/components/switch/Switch"),
28 | );
29 |
30 | export const LazyInput = lazy(
31 | () => import("fluent-solid/lib/components/input/Input"),
32 | );
33 |
34 | export const LazySlider = lazy(
35 | () => import("fluent-solid/lib/components/slider/Slider"),
36 | );
37 |
38 | export const LazyBadge = lazy(
39 | () => import("fluent-solid/lib/components/badge/Badge"),
40 | );
41 |
42 | export const LazySpinner = lazy(
43 | () => import("fluent-solid/lib/components/spinner/Spinner"),
44 | );
45 |
46 | export const LazyTooltip = lazy(
47 | () => import("fluent-solid/lib/components/tooltip/Tooltip"),
48 | );
49 |
50 | export const LazyDivider = lazy(
51 | () => import("fluent-solid/lib/components/divider/Divider"),
52 | );
53 |
--------------------------------------------------------------------------------
/src/themes.ts:
--------------------------------------------------------------------------------
1 | const thumbnails_base_url =
2 | "https://github.com/dwall-rs/dwall-assets/raw/refs/heads/main/thumbnails/";
3 |
4 | const thumbnails_count = {
5 | "Big Sur": 8,
6 | "Big Sur 1": 16,
7 | Catalina: 8,
8 | "Earth ISS": 16,
9 | "Earth View": 16,
10 | "Minya Konka": 24,
11 | Mojave: 16,
12 | "Monterey Bay 1": 16,
13 | "Monterey Graphic": 8,
14 | "Solar Gradients": 16,
15 | "The Beach": 8,
16 | "The Cliffs": 8,
17 | "The Desert": 8,
18 | "The Lake": 8,
19 | "Ventura Graphic": 5,
20 | };
21 |
22 | export const themes: ThemeItem[] = Object.entries(thumbnails_count)
23 | .map(([id, count]) => ({
24 | id,
25 | thumbnail: Array.from(
26 | { length: count },
27 | (_, i) => `${thumbnails_base_url}${id.replaceAll(" ", "")}/${i + 1}.avif`,
28 | ),
29 | }))
30 | .sort((a, b) => (a.id > b.id ? 1 : -1));
31 |
--------------------------------------------------------------------------------
/src/themes/vars.css.ts:
--------------------------------------------------------------------------------
1 | import { createGlobalTheme } from "@vanilla-extract/css";
2 |
3 | export const appVars = createGlobalTheme(":root", {
4 | contentWidth: "500px",
5 | });
6 |
--------------------------------------------------------------------------------
/src/utils/array.ts:
--------------------------------------------------------------------------------
1 | export const isSubset = (
2 | subset: T[],
3 | superset: T[],
4 | ): boolean => {
5 | const supersetSet = new Set(superset);
6 |
7 | return subset.every((item) => supersetSet.has(item));
8 | };
9 |
--------------------------------------------------------------------------------
/src/utils/color.ts:
--------------------------------------------------------------------------------
1 | export const detectColorMode = (): ColorMode =>
2 | window.matchMedia?.("(prefers-color-scheme: dark)").matches
3 | ? "DARK"
4 | : "LIGHT";
5 |
--------------------------------------------------------------------------------
/src/utils/i18n.ts:
--------------------------------------------------------------------------------
1 | export const translate = (
2 | translations: Translations,
3 | key: TranslationKey,
4 | params: Record = {},
5 | ) => {
6 | const translation = translations[key];
7 | if (!translation) return key;
8 |
9 | if (typeof translation === "string") {
10 | return translation;
11 | }
12 |
13 | let result = translation.template;
14 | for (const param of translation.params) {
15 | result = result.replace(`{{${param}}}`, params[param] || "");
16 | }
17 | return result;
18 | };
19 |
20 | export const translateErrorMessage = (
21 | translations: Translations,
22 | key: TranslationKey,
23 | error: unknown,
24 | params: Record = {},
25 | ) => {
26 | return translate(translations, key, { ...params, error: String(error) });
27 | };
28 |
--------------------------------------------------------------------------------
/src/utils/proxy.ts:
--------------------------------------------------------------------------------
1 | export const generateGitHubThumbnailMirrorUrl = (
2 | originalUrl: string,
3 | mirrorTemplate?: string,
4 | ): string => {
5 | if (!mirrorTemplate) return originalUrl;
6 |
7 | const baseMirrorPath = mirrorTemplate.slice(
8 | 0,
9 | mirrorTemplate.indexOf("") + "".length,
10 | );
11 |
12 | const originalPath = originalUrl.slice(originalUrl.indexOf("/raw/"));
13 |
14 | return (
15 | baseMirrorPath
16 | .replace("", "dwall-rs")
17 | .replace("", "dwall-assets") + originalPath
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2021",
4 | "useDefineForClassFields": true,
5 | "module": "ESNext",
6 | "lib": ["ES2021", "DOM", "DOM.Iterable"],
7 | "skipLibCheck": true,
8 | /* Bundler mode */
9 | "moduleResolution": "bundler",
10 | "allowImportingTsExtensions": true,
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "noEmit": true,
14 | "jsx": "preserve",
15 | "jsxImportSource": "solid-js",
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "paths": {
22 | "~/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["src", "types/**/*.d.ts"],
26 | "references": [
27 | {
28 | "path": "./tsconfig.node.json"
29 | }
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/types/config.d.ts:
--------------------------------------------------------------------------------
1 | interface Config {
2 | github_mirror_template?: string;
3 | selected_theme_id?: string;
4 | interval: number;
5 | image_format: string;
6 | themes_directory: string;
7 | coordinate_source: CoordinateSource;
8 | auto_detect_color_mode: boolean;
9 | lock_screen_wallpaper_enabled: boolean;
10 | monitor_specific_wallpapers: string | Record;
11 | }
12 |
13 | interface CoordinateSourceAutomatic {
14 | type: "AUTOMATIC";
15 | }
16 |
17 | interface CoordinateSourceManual {
18 | type: "MANUAL";
19 | latitude?: number;
20 | longitude?: number;
21 | }
22 |
23 | type CoordinateSource = CoordinateSourceAutomatic | CoordinateSourceManual;
24 |
--------------------------------------------------------------------------------
/types/context.d.ts:
--------------------------------------------------------------------------------
1 | type Update = import("@tauri-apps/plugin-updater").Update;
2 |
3 | type Accessor = import("solid-js").Accessor;
4 | type Resource = import("solid-js").Resource;
5 | type Setter = import("solid-js").Setter;
6 | type Refetcher = () => T | Promise | null | undefined;
7 |
--------------------------------------------------------------------------------
/types/i18n.d.ts:
--------------------------------------------------------------------------------
1 | type TranslationKey =
2 | | "button-apply"
3 | | "button-cancel"
4 | | "button-download"
5 | | "button-open-log-directory"
6 | | "button-select-folder"
7 | | "button-stop"
8 | | "help-automatically-switch-to-dark-mode"
9 | | "help-github-mirror-template"
10 | | "help-launch-at-startup"
11 | | "help-manually-set-coordinates"
12 | | "help-set-lock-screen-wallpaper-simultaneously"
13 | | "help-update-failed"
14 | | "label-automatically-retrieve-coordinates"
15 | | "label-automatically-switch-to-dark-mode"
16 | | "label-check-interval"
17 | | "label-github-mirror-template"
18 | | "label-launch-at-startup"
19 | | "label-select-monitor"
20 | | "label-set-lock-screen-wallpaper-simultaneously"
21 | | "label-source-code"
22 | | "label-themes-directory"
23 | | "label-version"
24 | | "tooltip-check-new-version"
25 | | "tooltip-new-version-available"
26 | | "tooltip-open-themes-directory"
27 | | "tooltip-settings"
28 | | "message-apply-theme-failed"
29 | | "message-change-themes-directory"
30 | | "message-check-interval-updated"
31 | | "message-disable-startup-failed"
32 | | "message-download-cancelled"
33 | | "message-download-faild"
34 | | "message-file-size-warning"
35 | | "message-github-mirror-template-updated"
36 | | "message-github-star"
37 | | "message-invalid-number-input"
38 | | "message-location-permission"
39 | | "message-coordinates-saved"
40 | | "message-number-too-small"
41 | | "message-number-too-large"
42 | | "message-saving-manual-coordinates"
43 | | "message-startup-failed"
44 | | "message-switch-auto-light-dark-mode-failed"
45 | | "message-switching-to-manual-coordinate-config"
46 | | "message-themes-directory-moved"
47 | | "message-update-available"
48 | | "message-update-failed"
49 | | "message-version-is-the-latest"
50 | | "placeholder-latitude"
51 | | "placeholder-longitude"
52 | | "unit-hour"
53 | | "unit-second"
54 | | "title-download-faild"
55 | | "title-downloading-new-version";
56 |
57 | type Translations = Record<
58 | TranslationKey,
59 | string | { template: string; params: string[] }
60 | >;
61 |
--------------------------------------------------------------------------------
/types/monitor.d.ts:
--------------------------------------------------------------------------------
1 | interface MonitorInfo {
2 | device_path: string;
3 | friendly_name: string;
4 | }
5 |
6 | interface MonitorItem {
7 | value: string;
8 | label: string;
9 | }
10 |
--------------------------------------------------------------------------------
/types/theme.d.ts:
--------------------------------------------------------------------------------
1 | interface ThemeItem {
2 | id: string;
3 | thumbnail: string[];
4 | }
5 |
6 | type ColorMode = "DARK" | "LIGHT";
7 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import solid from "vite-plugin-solid";
3 | import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
4 | import path from "node:path";
5 |
6 | const host = process.env.TAURI_DEV_HOST;
7 |
8 | const pathSrc = path.resolve(__dirname, "src");
9 |
10 | // https://vitejs.dev/config/
11 | export default defineConfig(async ({ mode }) => ({
12 | plugins: [
13 | solid(),
14 | vanillaExtractPlugin({
15 | identifiers: mode === "production" ? "short" : "debug",
16 | }),
17 | ],
18 |
19 | resolve: {
20 | alias: {
21 | "~/": `${pathSrc}/`,
22 | },
23 | },
24 |
25 | esbuild: {
26 | drop: mode === "production" ? ["console"] : [],
27 | },
28 |
29 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
30 | //
31 | // 1. prevent vite from obscuring rust errors
32 | clearScreen: false,
33 | // 2. tauri expects a fixed port, fail if that port is not available
34 | server: {
35 | port: 1420,
36 | strictPort: true,
37 | host: host || false,
38 | hmr: host
39 | ? {
40 | protocol: "ws",
41 | host,
42 | port: 1421,
43 | }
44 | : undefined,
45 | watch: {
46 | // 3. tell vite to ignore watching `src-tauri`
47 | ignored: ["**/src-tauri/**"],
48 | },
49 | },
50 | }));
51 |
--------------------------------------------------------------------------------