├── folder-alias.json ├── src-tauri ├── src │ ├── core │ │ ├── store │ │ │ ├── mod.rs │ │ │ └── settings.rs │ │ ├── mod.rs │ │ ├── window │ │ │ ├── linux │ │ │ │ ├── mod.rs │ │ │ │ ├── ext.rs │ │ │ │ └── window_impl.rs │ │ │ ├── mod.rs │ │ │ ├── windows.rs │ │ │ └── macos.rs │ │ ├── util │ │ │ ├── mod.rs │ │ │ ├── macos.rs │ │ │ └── linux.rs │ │ └── setup │ │ │ ├── macos.rs │ │ │ ├── linux.rs │ │ │ ├── mod.rs │ │ │ └── windows.rs │ ├── main.rs │ ├── timer.rs │ ├── lib.rs │ └── commands.rs ├── build.rs ├── icons │ ├── icon.icns │ ├── icon.ico │ ├── shui.jpg │ ├── WechatIMG5.jpg │ ├── WechatIMG6.jpg │ ├── icon copy.ico │ ├── icon-tray.ico │ ├── icon_16x16.png │ ├── icon_32x32.png │ ├── icon_128x128.png │ ├── icon_16x16@2x.png │ ├── icon_256x256.png │ ├── icon_32x32@2x.png │ ├── icon_512x512.png │ ├── shui │ │ ├── icon.iconset │ │ └── original.png │ ├── icon_128x128@2x.png │ ├── icon_256x256@2x.png │ └── icon_512x512@2x.png ├── tauri.windows.conf.json ├── .gitignore ├── Info.plist ├── capabilities │ ├── desktop.json │ └── default.json ├── tauri.linux.conf.json ├── tauri.conf.json └── Cargo.toml ├── src ├── app │ ├── favicon.ico │ ├── page.tsx │ ├── setting │ │ ├── shortcut │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── about │ │ │ └── page.tsx │ │ └── reminder │ │ │ └── page.tsx │ ├── layout.tsx │ ├── reminder │ │ ├── index.css │ │ └── page.tsx │ └── globals.css ├── postcss.config.mjs ├── lib │ ├── utils.ts │ └── constants.ts ├── components │ ├── ui │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── separator.tsx │ │ ├── progress.tsx │ │ ├── input.tsx │ │ ├── switch.tsx │ │ ├── badge.tsx │ │ ├── popover.tsx │ │ ├── tooltip.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ ├── sheet.tsx │ │ ├── command.tsx │ │ └── select.tsx │ └── app-sidebar.tsx ├── eslint.config.mjs ├── utils │ ├── store.ts │ ├── updater.ts │ └── notification.ts ├── .gitignore ├── hooks │ ├── use-mobile.ts │ ├── use-platform.ts │ ├── use-updater.ts │ └── use-tray.ts └── README.md ├── public ├── screenshot-0.png ├── screenshot-1.png ├── screenshot-2.png ├── screenshot-3.png ├── install_error.png ├── sounds │ └── water-drop.mp3 ├── qrcode_wechat_dark.png ├── qrcode_wechat_light.png ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── .vscode └── extensions.json ├── postcss.config.mjs ├── next-env.d.ts ├── next.config.ts ├── .gitignore ├── index.html ├── components.json ├── package-bak.json ├── tsconfig.json ├── Cargo.toml ├── .github └── workflows │ ├── updater.mjs │ └── publish.yaml ├── package.json ├── README.md ├── README.en.md └── LICENSE /folder-alias.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src-tauri/src/core/store/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod settings; 2 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/screenshot-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/public/screenshot-0.png -------------------------------------------------------------------------------- /public/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/public/screenshot-1.png -------------------------------------------------------------------------------- /public/screenshot-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/public/screenshot-2.png -------------------------------------------------------------------------------- /public/screenshot-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/public/screenshot-3.png -------------------------------------------------------------------------------- /public/install_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/public/install_error.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/shui.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/shui.jpg -------------------------------------------------------------------------------- /src-tauri/src/core/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod setup; 2 | pub mod store; 3 | pub mod util; 4 | pub mod window; 5 | -------------------------------------------------------------------------------- /public/sounds/water-drop.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/public/sounds/water-drop.mp3 -------------------------------------------------------------------------------- /public/qrcode_wechat_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/public/qrcode_wechat_dark.png -------------------------------------------------------------------------------- /public/qrcode_wechat_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/public/qrcode_wechat_light.png -------------------------------------------------------------------------------- /src-tauri/icons/WechatIMG5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/WechatIMG5.jpg -------------------------------------------------------------------------------- /src-tauri/icons/WechatIMG6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/WechatIMG6.jpg -------------------------------------------------------------------------------- /src-tauri/icons/icon copy.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon copy.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon-tray.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon-tray.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon_16x16.png -------------------------------------------------------------------------------- /src-tauri/icons/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon_32x32.png -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export default function Home() { 4 | return
HOME
; 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon_128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon_16x16@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon_256x256.png -------------------------------------------------------------------------------- /src-tauri/icons/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon_32x32@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon_512x512.png -------------------------------------------------------------------------------- /src-tauri/icons/shui/icon.iconset: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/shui/icon.iconset -------------------------------------------------------------------------------- /src-tauri/icons/shui/original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/shui/original.png -------------------------------------------------------------------------------- /src-tauri/icons/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon_128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon_256x256@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rock-zhang/Shui/HEAD/src-tauri/icons/icon_512x512@2x.png -------------------------------------------------------------------------------- /src/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /src-tauri/src/core/window/linux/mod.rs: -------------------------------------------------------------------------------- 1 | mod ext; 2 | mod window_impl; 3 | 4 | pub use ext::LinuxWindowExt; 5 | pub use window_impl::*; 6 | -------------------------------------------------------------------------------- /src-tauri/tauri.windows.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": { 3 | "trayIcon": { 4 | "iconPath": "icons/icon.ico" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /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/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | shui_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | import { version } from "./package.json"; 3 | 4 | const nextConfig: NextConfig = { 5 | output: "export", 6 | distDir: "dist", 7 | trailingSlash: true, 8 | skipTrailingSlashRedirect: true, 9 | env: { 10 | APP_VERSION: version, 11 | }, 12 | }; 13 | 14 | export default nextConfig; 15 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /src-tauri/src/timer.rs: -------------------------------------------------------------------------------- 1 | use parking_lot::Mutex; 2 | use std::sync::atomic::AtomicBool; 3 | use std::sync::Arc; 4 | use std::time::Instant; 5 | 6 | lazy_static::lazy_static! { 7 | pub static ref TIMER_STATE: Arc> = Arc::new(Mutex::new(Instant::now())); 8 | pub static ref IS_RUNNING: Arc = Arc::new(AtomicBool::new(true)); 9 | } 10 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | /target/ 15 | /.next/ 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const STORE_NAME = { 2 | config: "config_store.json", 3 | drink_history: "drink_history_store.json", 4 | }; 5 | 6 | export const PLATFORM_OS = { 7 | LINUX: "linux", 8 | MACOS: "macos", 9 | IOS: "ios", 10 | FREEBSD: "freebsd", 11 | DRAGONFLY: "dragonfly", 12 | NETBSD: "netbsd", 13 | OPENBSD: "openbsd", 14 | SOLARIS: "solaris", 15 | ANDROID: "android", 16 | WINDOWS: "windows", 17 | }; 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tauri + React + Typescript 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src-tauri/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | Chinese 7 | LSUIElement 8 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /src-tauri/capabilities/desktop.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "desktop-capability", 3 | "platforms": [ 4 | "macOS", 5 | "windows", 6 | "linux" 7 | ], 8 | "windows": [ 9 | "main" 10 | ], 11 | "permissions": [ 12 | "autostart:default", 13 | "process:default", 14 | "process:allow-restart", 15 | "process:allow-exit", 16 | "updater:default", 17 | "core:window:allow-set-always-on-top", 18 | "core:window:allow-unminimize" 19 | ] 20 | } -------------------------------------------------------------------------------- /src/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /src/utils/store.ts: -------------------------------------------------------------------------------- 1 | import { load } from "@tauri-apps/plugin-store"; 2 | import { STORE_NAME } from "@/lib/constants"; 3 | 4 | export async function getGeneralConfig() { 5 | const store = await load(STORE_NAME.config, { autoSave: false }); 6 | const [generalSetting] = await Promise.all([ 7 | store.get<{ 8 | isAutoStart: boolean; 9 | isCountDown: boolean; 10 | isFullScreen: boolean; 11 | }>("general"), 12 | ]); 13 | 14 | // 旧版本升级上来的用户,没有 isFullScreen 配置,默认开启全屏 15 | const isFullScreen = generalSetting?.isFullScreen === false ? false : true; 16 | 17 | return { 18 | ...generalSetting, 19 | isFullScreen, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /src/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const MOBILE_BREAKPOINT = 768; 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState( 7 | undefined 8 | ); 9 | 10 | React.useEffect(() => { 11 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); 12 | const onChange = () => { 13 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 14 | }; 15 | mql.addEventListener("change", onChange); 16 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 17 | return () => mql.removeEventListener("change", onChange); 18 | }, []); 19 | 20 | return !!isMobile; 21 | } 22 | -------------------------------------------------------------------------------- /package-bak.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shui", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "next dev --turbopack", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri" 11 | }, 12 | "dependencies": { 13 | "react": "^18.3.1", 14 | "react-dom": "^18.3.1", 15 | "@tauri-apps/api": "^2", 16 | "@tauri-apps/plugin-opener": "^2" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^18.3.1", 20 | "@types/react-dom": "^18.3.1", 21 | "@vitejs/plugin-react": "^4.3.4", 22 | "typescript": "~5.6.2", 23 | "vite": "^6.0.3", 24 | "@tauri-apps/cli": "^2" 25 | } 26 | } -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner, ToasterProps } from "sonner" 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 22 | ) 23 | } 24 | 25 | export { Toaster } 26 | -------------------------------------------------------------------------------- /src-tauri/src/core/window/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "macos")] 2 | mod macos; 3 | 4 | #[cfg(target_os = "windows")] 5 | mod windows; 6 | 7 | #[cfg(target_os = "linux")] 8 | mod linux; 9 | 10 | #[cfg(target_os = "macos")] 11 | pub use macos::*; 12 | 13 | #[cfg(target_os = "windows")] 14 | pub use windows::*; 15 | 16 | #[cfg(target_os = "linux")] 17 | pub use linux::*; 18 | 19 | pub fn show_reminder_windows(app_handle: &tauri::AppHandle) { 20 | show_reminder(&app_handle); 21 | } 22 | 23 | pub fn hide_reminder_windows(app_handle: &tauri::AppHandle) { 24 | hide_reminder(&app_handle); 25 | } 26 | 27 | pub fn hide_reminder_window(app_handle: &tauri::AppHandle, label: &str) { 28 | hide_reminder_single(&app_handle, &label); 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/use-platform.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { platform } from "@tauri-apps/plugin-os"; 3 | import { PLATFORM_OS } from "@/lib/constants"; 4 | 5 | export function usePlatform() { 6 | const [isMacOS, setIsMacOS] = useState(false); 7 | const [isWindows, setIsWindows] = useState(false); 8 | const [isLinux, setIsLinux] = useState(false); 9 | 10 | useEffect(() => { 11 | // 检查操作系统 12 | const currentPlatform = platform(); 13 | setIsMacOS(currentPlatform === PLATFORM_OS.MACOS); 14 | setIsWindows(currentPlatform === PLATFORM_OS.WINDOWS); 15 | setIsLinux(currentPlatform === PLATFORM_OS.LINUX); 16 | }, []); 17 | 18 | return { 19 | isWindows, 20 | isMacOS, 21 | isLinux, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src-tauri/src/core/util/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "linux")] 2 | mod linux; 3 | #[cfg(target_os = "macos")] 4 | mod macos; 5 | 6 | #[cfg(target_os = "linux")] 7 | pub use linux::*; 8 | #[cfg(target_os = "macos")] 9 | pub use macos::*; 10 | 11 | // #[cfg(target_os = "windows")] 12 | // mod windows; 13 | // #[cfg(target_os = "windows")] 14 | // pub use windows::*; 15 | 16 | pub fn is_frontapp_in_whitelist(whitelist_apps: &Vec) -> bool { 17 | #[cfg(target_os = "macos")] 18 | { 19 | return check_whitelist(whitelist_apps); 20 | } 21 | 22 | false 23 | } 24 | 25 | pub async fn get_installed_apps() -> Vec { 26 | #[cfg(target_os = "macos")] 27 | { 28 | return get_local_installed_apps().await; 29 | } 30 | 31 | vec![] 32 | } 33 | -------------------------------------------------------------------------------- /src/app/setting/shortcut/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export default function Shortcut() { 4 | return ( 5 |
6 |

快捷键

7 | 8 |
9 |
10 | 13 |

14 | 在提醒界面按下 Esc 键可关闭提醒 15 |

16 |
17 | 18 | Esc 19 | 20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Separator({ 9 | className, 10 | orientation = "horizontal", 11 | decorative = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 25 | ) 26 | } 27 | 28 | export { Separator } 29 | -------------------------------------------------------------------------------- /src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ProgressPrimitive from "@radix-ui/react-progress"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | function Progress({ 9 | className, 10 | value, 11 | ...props 12 | }: React.ComponentProps) { 13 | return ( 14 | 22 | 27 | 28 | ); 29 | } 30 | 31 | export { Progress }; 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./src/*" 28 | ] 29 | } 30 | }, 31 | "include": [ 32 | "**/*.ts", 33 | "**/*.tsx", 34 | ".next/types/**/*.ts", 35 | "next-env.d.ts", 36 | "dist/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "node_modules", 40 | "dist" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver="2" 3 | members = [ 4 | "src-tauri" 5 | ] 6 | 7 | [workspace.dependencies] 8 | tauri = { version = "2", features = [ "tray-icon", "macos-private-api", "image-ico", "system-tray" ] } 9 | serde = { version = "1.0", features = ["derive"] } 10 | serde_json = "1" 11 | fs_extra = "1" 12 | log = "0.4" 13 | cocoa = "0.25" 14 | tauri-plugin = { version = "2", features = ["build"] } 15 | tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" } 16 | # tauri-plugin-shell = "2" 17 | # tauri-plugin-fs-pro = "2" 18 | # tauri-plugin-eco-window = { path = "./src-tauri/src/plugins/window" } 19 | # tauri-plugin-eco-locale = { path = "./src-tauri/src/plugins/locale" } 20 | # tauri-plugin-eco-clipboard = { path = "./src-tauri/src/plugins/clipboard" } 21 | # tauri-plugin-eco-ocr = { path = "./src-tauri/src/plugins/ocr" } 22 | # tauri-plugin-eco-paste = { path = "./src-tauri/src/plugins/paste" } 23 | # tauri-plugin-eco-autostart = { path = "./src-tauri/src/plugins/autostart" } -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: { 17 | default: "喝水提醒", 18 | template: "%s - 喝水提醒", 19 | }, 20 | description: "一个帮助你保持健康饮水习惯的应用", 21 | }; 22 | 23 | export const viewport = { 24 | width: "device-width", 25 | initialScale: 1, 26 | maximumScale: 1, 27 | userScalable: false, 28 | }; 29 | 30 | export default function RootLayout({ 31 | children, 32 | }: Readonly<{ 33 | children: React.ReactNode; 34 | }>) { 35 | return ( 36 | 37 | 40 | {children} 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /src/utils/updater.ts: -------------------------------------------------------------------------------- 1 | import { check } from "@tauri-apps/plugin-updater"; 2 | import { relaunch } from "@tauri-apps/plugin-process"; 3 | 4 | const update = await check(); 5 | if (update) { 6 | console.log( 7 | `found update ${update.version} from ${update.date} with notes ${update.body}` 8 | ); 9 | let downloaded = 0; 10 | let contentLength = 0; 11 | // alternatively we could also call update.download() and update.install() separately 12 | await update.downloadAndInstall((event) => { 13 | switch (event.event) { 14 | case "Started": 15 | contentLength = event.data.contentLength as number; 16 | console.log(`started downloading ${event.data.contentLength} bytes`); 17 | break; 18 | case "Progress": 19 | downloaded += event.data.chunkLength; 20 | console.log(`downloaded ${downloaded} from ${contentLength}`); 21 | break; 22 | case "Finished": 23 | console.log("download finished"); 24 | break; 25 | } 26 | }); 27 | 28 | console.log("update installed"); 29 | await relaunch(); 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/updater.mjs: -------------------------------------------------------------------------------- 1 | // 注意要安装@actions/github依赖 2 | import { context, getOctokit } from "@actions/github"; 3 | import { readFile } from "node:fs/promises"; 4 | 5 | // 在容器中可以通过env环境变量来获取参数 6 | const octokit = getOctokit(process.env.GITHUB_TOKEN); 7 | 8 | const updateRelease = async () => { 9 | // 获取updater tag的release 10 | const { data: release } = await octokit.rest.repos.getReleaseByTag({ 11 | owner: context.repo.owner, 12 | repo: context.repo.repo, 13 | tag: "updater", 14 | }); 15 | // 删除旧的的文件 16 | const deletePromises = release.assets 17 | .filter((item) => item.name === "latest.json") 18 | .map(async (item) => { 19 | await octokit.rest.repos.deleteReleaseAsset({ 20 | owner: context.repo.owner, 21 | repo: context.repo.repo, 22 | asset_id: item.id, 23 | }); 24 | }); 25 | 26 | await Promise.all(deletePromises); 27 | 28 | // 上传新的文件 29 | const file = await readFile("latest.json", { encoding: "utf-8" }); 30 | 31 | await octokit.rest.repos.uploadReleaseAsset({ 32 | owner: context.repo.owner, 33 | repo: context.repo.repo, 34 | release_id: release.id, 35 | name: "latest.json", 36 | data: file, 37 | }); 38 | }; 39 | 40 | updateRelease(); 41 | -------------------------------------------------------------------------------- /src/app/reminder/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: transparent; 3 | } 4 | .reminder-page { 5 | /* &::before { 6 | content: ""; 7 | position: absolute; 8 | inset: 0; 9 | background-color: rgb(174 180 199 / 68%); 10 | z-index: -1; 11 | } */ 12 | background: radial-gradient(circle at 60% 90%, #ff7eb3, #0000 10%), 13 | radial-gradient(circle at 20px 20px, #7afcff, #0000 15%), 14 | radial-gradient(circle at 80% 30%, #ffd65f, #0000 12%), 15 | radial-gradient(circle at 40% 20%, #2ecc71, #0000 8%), 16 | radial-gradient(circle at 85% 60%, #ff9966, #0000 14%), 17 | radial-gradient(circle at 30% 70%, #99ccff, #0000 18%), 18 | radial-gradient(circle at 70% 50%, #ff99cc, #0000 16%), 19 | radial-gradient(circle at 15% 25%, #ffcc99, #0000 13%), 20 | radial-gradient(circle at 75% 80%, #99ffcc, #0000 11%), 21 | radial-gradient(circle at 95% 20%, #ff99ff, #0000 17%), 22 | radial-gradient(circle at 50% 40%, #ffff99, #0000 9%), 23 | radial-gradient(circle at 10% 90%, #99ff99, #0000 15%), 24 | radial-gradient(circle at 90% 10%, #ff9999, #0000 12%), 25 | radial-gradient(circle at 45% 60%, #9999ff, #0000 14%), 26 | radial-gradient(circle at 65% 15%, #ffb366, #0000 10%), 27 | rgba(255, 255, 255, 0.8); 28 | } 29 | -------------------------------------------------------------------------------- /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": [ 6 | "main", 7 | "reminder_0", 8 | "reminder_1", 9 | "reminder_2", 10 | "reminder_3" 11 | ], 12 | "permissions": [ 13 | "core:default", 14 | "core:tray:allow-get-by-id", 15 | "core:window:allow-show", 16 | "core:window:allow-hide", 17 | "core:window:allow-start-dragging", 18 | "core:window:deny-internal-toggle-maximize", 19 | "core:window:allow-get-all-windows", 20 | "core:window:allow-close", 21 | "core:window:allow-set-focus", 22 | "core:webview:allow-set-webview-focus", 23 | "core:window:allow-set-decorations", 24 | "store:default", 25 | "core:webview:allow-get-all-webviews", 26 | "global-shortcut:allow-register", 27 | "global-shortcut:allow-unregister-all", 28 | "global-shortcut:allow-is-registered", 29 | "opener:default", 30 | "notification:default", 31 | "autostart:default", 32 | "autostart:allow-enable", 33 | "autostart:allow-disable", 34 | "autostart:allow-is-enabled", 35 | "clipboard-manager:allow-write-text", 36 | "process:default", 37 | "process:allow-restart", 38 | "process:allow-exit" 39 | ] 40 | } -------------------------------------------------------------------------------- /src-tauri/tauri.linux.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": { 3 | "trayIcon": { 4 | "iconPath": "icons/icon_32x32.png", 5 | "iconAsTemplate": false, 6 | "title": "Shui", 7 | "id": "main-tray", 8 | "showMenuOnLeftClick": true 9 | }, 10 | "windows": [ 11 | { 12 | "label": "main", 13 | "title": "Shui", 14 | "url": "setting/", 15 | "width": 680, 16 | "height": 460, 17 | "resizable": false, 18 | "visible": true, 19 | "maximizable": false, 20 | "hiddenTitle": true, 21 | "skipTaskbar": true, 22 | "titleBarStyle": "Overlay", 23 | "dragDropEnabled": false 24 | } 25 | ] 26 | }, 27 | "bundle": { 28 | "linux": { 29 | "deb": { 30 | "files": { 31 | "/usr/share/pixmaps/shui.png": "icons/icon_512x512.png", 32 | "/usr/share/icons/hicolor/16x16/apps/shui.png": "icons/icon_16x16.png", 33 | "/usr/share/icons/hicolor/32x32/apps/shui.png": "icons/icon_32x32.png", 34 | "/usr/share/icons/hicolor/128x128/apps/shui.png": "icons/icon_128x128.png", 35 | "/usr/share/icons/hicolor/256x256/apps/shui.png": "icons/icon_256x256.png", 36 | "/usr/share/icons/hicolor/512x512/apps/shui.png": "icons/icon_512x512.png" 37 | } 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SwitchPrimitive from "@radix-ui/react-switch"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | function Switch({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | 27 | 28 | ); 29 | } 30 | 31 | export { Switch }; 32 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/src/core/setup/macos.rs: -------------------------------------------------------------------------------- 1 | use crate::timer::IS_RUNNING; 2 | 3 | use std::thread::{self, sleep}; 4 | use std::time::Duration; 5 | 6 | use std::sync::atomic::Ordering; 7 | 8 | extern crate core_foundation; 9 | 10 | use core_foundation::{base::TCFType, base::ToVoid, dictionary::CFDictionary, string::CFString}; 11 | 12 | extern "C" { 13 | fn CGSessionCopyCurrentDictionary() -> core_foundation::dictionary::CFDictionaryRef; 14 | } 15 | 16 | pub fn monitor_lock_screen() { 17 | let mut previous_lock_state = false; 18 | let lock_key = CFString::new("CGSSessionScreenIsLocked"); 19 | 20 | loop { 21 | unsafe { 22 | let session_dictionary_ref = CGSessionCopyCurrentDictionary(); 23 | let session_dictionary: CFDictionary = 24 | CFDictionary::wrap_under_create_rule(session_dictionary_ref); 25 | let current_lock_state = session_dictionary.find(lock_key.to_void()).is_some(); 26 | 27 | if previous_lock_state != current_lock_state { 28 | previous_lock_state = current_lock_state; 29 | IS_RUNNING.store(!current_lock_state, Ordering::SeqCst); 30 | let (status, action) = if current_lock_state { 31 | ("锁屏", "停止") 32 | } else { 33 | ("解锁", "开始") 34 | }; 35 | println!("系统{},{}计时", status, action); 36 | } 37 | } 38 | thread::sleep(Duration::from_secs(1)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/notification.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isPermissionGranted, 3 | requestPermission, 4 | sendNotification, 5 | } from "@tauri-apps/plugin-notification"; 6 | 7 | export const sendReminderNotification = async () => { 8 | let permissionGranted = await isPermissionGranted(); 9 | 10 | if (!permissionGranted) { 11 | const permission = await requestPermission(); 12 | permissionGranted = permission === "granted"; 13 | } 14 | 15 | if (permissionGranted) { 16 | sendNotification({ 17 | title: "⏰该喝水啦", 18 | body: "站起来喝杯水,顺便活动一下身体吧!", 19 | channelId: "reminder", 20 | }); 21 | // await registerActionTypes([ 22 | // { 23 | // id: "reminder", 24 | // actions: [ 25 | // { 26 | // id: "50ml", 27 | // title: "50ml", 28 | // }, 29 | // { 30 | // id: "100ml", 31 | // title: "100ml", 32 | // }, 33 | // ], 34 | // }, 35 | // ]); 36 | 37 | // await createChannel({ 38 | // id: "reminder", 39 | // name: "Messages", 40 | // description: "Notifications for new messages", 41 | // importance: Importance.High, 42 | // visibility: Visibility.Private, 43 | // lights: true, 44 | // lightColor: "#ff0000", 45 | // vibration: true, 46 | // sound: "notification_sound", 47 | // }); 48 | // await onAction((notification) => { 49 | // console.log("Action performed:", notification); 50 | // }); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /src/app/setting/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { SidebarProvider } from "@/components/ui/sidebar"; 3 | import { AppSidebar } from "@/components/app-sidebar"; 4 | import { useEffect } from "react"; 5 | import { listen } from "@tauri-apps/api/event"; 6 | import { invoke } from "@tauri-apps/api/core"; 7 | import { Toaster } from "@/components/ui/sonner"; 8 | import { usePlatform } from "@/hooks/use-platform"; 9 | import { sendReminderNotification } from "@/utils/notification"; 10 | import { getGeneralConfig } from "@/utils/store"; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | const { isMacOS } = usePlatform(); 18 | 19 | useEffect(() => { 20 | const unlisten = listen("timer-complete", async (event) => { 21 | console.log("Timer completed", event); 22 | 23 | if ((await getGeneralConfig()).isFullScreen) { 24 | invoke("call_reminder"); 25 | } else { 26 | sendReminderNotification(); 27 | invoke("reset_timer"); 28 | } 29 | // 这里可以添加倒计时结束后的处理逻辑 30 | }); 31 | 32 | return () => { 33 | unlisten.then((unsubscribe) => unsubscribe()); 34 | }; 35 | }, []); 36 | 37 | return ( 38 | { 43 | if (process.env.NODE_ENV === "production") e.preventDefault(); 44 | }} 45 | > 46 | {isMacOS && ( 47 |
51 | )} 52 | 53 |
{children}
54 | 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod commands; 2 | mod core; 3 | use core::setup; 4 | mod timer; 5 | use tauri::Manager; 6 | use tauri_plugin_autostart::MacosLauncher; 7 | 8 | pub fn run() { 9 | let mut builder = tauri::Builder::default(); 10 | 11 | // 通用插件 12 | builder = builder 13 | .plugin(tauri_plugin_os::init()) 14 | .plugin(tauri_plugin_process::init()) 15 | .plugin(tauri_plugin_updater::Builder::new().build()) 16 | .plugin(tauri_plugin_notification::init()) 17 | .plugin(tauri_plugin_clipboard_manager::init()) 18 | .plugin(tauri_plugin_opener::init()) 19 | .plugin(tauri_plugin_global_shortcut::Builder::new().build()) 20 | .plugin(tauri_plugin_store::Builder::new().build()) 21 | .plugin(tauri_plugin_autostart::init( 22 | MacosLauncher::LaunchAgent, 23 | Some(vec!["--silent"]), 24 | )); 25 | 26 | // macOS 特有插件 27 | #[cfg(target_os = "macos")] 28 | { 29 | builder = builder.plugin(tauri_nspanel::init()) 30 | } 31 | 32 | builder 33 | .setup(|app| { 34 | let app_handle = app.app_handle(); 35 | 36 | setup::default(&app_handle); 37 | 38 | Ok(()) 39 | }) 40 | .invoke_handler(tauri::generate_handler![ 41 | commands::call_reminder, 42 | commands::setting, 43 | commands::hide_reminder_windows, 44 | commands::hide_reminder_window, 45 | commands::reset_timer, 46 | commands::pause_timer, 47 | commands::start_timer, 48 | commands::get_app_runtime_info, 49 | commands::get_installed_apps, 50 | commands::quit 51 | ]) 52 | .run(tauri::generate_context!()) 53 | .expect("error while running tauri application"); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Popover({ 9 | ...props 10 | }: React.ComponentProps) { 11 | return 12 | } 13 | 14 | function PopoverTrigger({ 15 | ...props 16 | }: React.ComponentProps) { 17 | return 18 | } 19 | 20 | function PopoverContent({ 21 | className, 22 | align = "center", 23 | sideOffset = 4, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 28 | 38 | 39 | ) 40 | } 41 | 42 | function PopoverAnchor({ 43 | ...props 44 | }: React.ComponentProps) { 45 | return 46 | } 47 | 48 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shui-web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "tauri": "tauri", 7 | "dev": "next dev --turbo", 8 | "dev:prod": "NODE_ENV=production next dev --turbo", 9 | "build": "next build", 10 | "build:dmg": "tauri build", 11 | "start": "next start", 12 | "lint": "next lint" 13 | }, 14 | "dependencies": { 15 | "@radix-ui/react-dialog": "^1.1.10", 16 | "@radix-ui/react-popover": "^1.1.10", 17 | "@radix-ui/react-progress": "^1.1.2", 18 | "@radix-ui/react-select": "^2.1.6", 19 | "@radix-ui/react-separator": "^1.1.2", 20 | "@radix-ui/react-slot": "^1.2.0", 21 | "@radix-ui/react-switch": "^1.1.3", 22 | "@radix-ui/react-tooltip": "^1.1.8", 23 | "@tauri-apps/api": "^2", 24 | "@tauri-apps/plugin-autostart": "~2", 25 | "@tauri-apps/plugin-clipboard-manager": "~2", 26 | "@tauri-apps/plugin-global-shortcut": "~2", 27 | "@tauri-apps/plugin-notification": "~2", 28 | "@tauri-apps/plugin-opener": "~2", 29 | "@tauri-apps/plugin-os": "~2", 30 | "@tauri-apps/plugin-process": "~2", 31 | "@tauri-apps/plugin-store": "~2", 32 | "@tauri-apps/plugin-updater": "~2", 33 | "class-variance-authority": "^0.7.1", 34 | "clsx": "^2.1.1", 35 | "cmdk": "^1.1.1", 36 | "lucide-react": "^0.486.0", 37 | "next": "15.2.4", 38 | "next-themes": "^0.4.6", 39 | "react": "^19.0.0", 40 | "react-dom": "^19.0.0", 41 | "sonner": "^2.0.3", 42 | "tailwind-merge": "^3.1.0", 43 | "tw-animate-css": "^1.2.5" 44 | }, 45 | "devDependencies": { 46 | "@actions/github": "^6.0.0", 47 | "@eslint/eslintrc": "^3", 48 | "@tailwindcss/postcss": "^4", 49 | "@tauri-apps/cli": "^2", 50 | "@types/node": "^20", 51 | "@types/react": "^19", 52 | "@types/react-dom": "^19", 53 | "eslint": "^9", 54 | "eslint-config-next": "15.2.4", 55 | "tailwindcss": "^4", 56 | "typescript": "^5" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/components/app-sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { AlarmClock, Keyboard, Settings, Info } from "lucide-react"; 3 | import { usePathname } from "next/navigation"; 4 | import Link from "next/link"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | import { 8 | Sidebar, 9 | SidebarContent, 10 | SidebarGroup, 11 | SidebarGroupContent, 12 | SidebarMenu, 13 | SidebarMenuButton, 14 | SidebarMenuItem, 15 | } from "@/components/ui/sidebar"; 16 | import { usePlatform } from "@/hooks/use-platform"; 17 | 18 | const items = [ 19 | { 20 | title: "通用", 21 | url: "/setting/", 22 | icon: Settings, 23 | }, 24 | { 25 | title: "提醒", 26 | url: "/setting/reminder/", 27 | icon: AlarmClock, 28 | }, 29 | { 30 | title: "快捷键", 31 | url: "/setting/shortcut/", 32 | icon: Keyboard, // 使用 Keyboard 图标替换 Search 33 | }, 34 | { 35 | title: "关于", 36 | url: "/setting/about/", 37 | icon: Info, 38 | }, 39 | ]; 40 | 41 | export function AppSidebar() { 42 | const { isMacOS } = usePlatform(); 43 | 44 | const pathname = usePathname(); 45 | console.log("pathname", pathname); 46 | 47 | return ( 48 | 49 | 50 | 51 | 52 | 53 | {items.map((item) => ( 54 | 55 | 62 | 63 | 64 | {item.title} 65 | 66 | 67 | 68 | ))} 69 | 70 | 71 | 72 | 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function TooltipProvider({ 9 | delayDuration = 0, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function Tooltip({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | function TooltipTrigger({ 32 | ...props 33 | }: React.ComponentProps) { 34 | return 35 | } 36 | 37 | function TooltipContent({ 38 | className, 39 | sideOffset = 0, 40 | children, 41 | ...props 42 | }: React.ComponentProps) { 43 | return ( 44 | 45 | 54 | {children} 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 62 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "Shui", 4 | "version": "0.2.20", 5 | "identifier": "com.shui.app", 6 | "build": { 7 | "beforeDevCommand": "yarn dev", 8 | "devUrl": "http://localhost:3000/", 9 | "beforeBuildCommand": "yarn build", 10 | "frontendDist": "../dist" 11 | }, 12 | "app": { 13 | "macOSPrivateApi": true, 14 | "security": { 15 | "csp": null 16 | }, 17 | "trayIcon": { 18 | "iconPath": "icons/icon-tray.ico", 19 | "iconAsTemplate": true, 20 | "title": "Shui", 21 | "id": "main-tray", 22 | "showMenuOnLeftClick": true 23 | }, 24 | "windows": [ 25 | { 26 | "label": "main", 27 | "title": "Shui", 28 | "url": "setting/", 29 | "width": 680, 30 | "height": 460, 31 | "resizable": false, 32 | "visible": true, 33 | "maximizable": false, 34 | "hiddenTitle": true, 35 | "skipTaskbar": true, 36 | "titleBarStyle": "Overlay", 37 | "dragDropEnabled": false, 38 | "windowEffects": { 39 | "effects": [ 40 | "sidebar" 41 | ], 42 | "state": "active" 43 | } 44 | } 45 | ] 46 | }, 47 | "bundle": { 48 | "createUpdaterArtifacts": true, 49 | "active": true, 50 | "targets": "all", 51 | "icon": [ 52 | "icons/icon_32x32.png", 53 | "icons/icon_32x32@2x.png", 54 | "icons/icon_128x128.png", 55 | "icons/icon_128x128@2x.png", 56 | "icons/icon_256x256.png", 57 | "icons/icon_256x256@2x.png", 58 | "icons/icon_512x512.png", 59 | "icons/icon_512x512@2x.png", 60 | "icons/icon.icns", 61 | "icons/icon.ico" 62 | ], 63 | "resources": [ 64 | "icons/**/*" 65 | ] 66 | }, 67 | "plugins": { 68 | "updater": { 69 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMzOUFERENERDlCNzYxQTQKUldTa1liZlp6ZDJhd3lmUDh4TktmL092dVNuZlRXbWtiS2tYUm9uY29xRFdVOVZja0N6aDdpQUQK", 70 | "endpoints": [ 71 | "https://github.com/rock-zhang/Shui/releases/download/updater/latest.json" 72 | ] 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /src-tauri/src/core/window/linux/ext.rs: -------------------------------------------------------------------------------- 1 | // Linux特定的窗口扩展功能 2 | use std::process::Command; 3 | 4 | pub struct LinuxWindowExt; 5 | 6 | impl LinuxWindowExt { 7 | /// 检测当前桌面环境 8 | pub fn detect_desktop_environment() -> String { 9 | if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") { 10 | return desktop; 11 | } 12 | 13 | if let Ok(desktop) = std::env::var("DESKTOP_SESSION") { 14 | return desktop; 15 | } 16 | 17 | "unknown".to_string() 18 | } 19 | 20 | /// 获取屏幕工作区信息 21 | pub fn get_workspaces() -> Vec { 22 | let mut workspaces = Vec::new(); 23 | 24 | // 尝试通过wmctrl获取工作区 25 | if let Ok(output) = Command::new("wmctrl").args(["-d"]).output() { 26 | let output_str = String::from_utf8_lossy(&output.stdout); 27 | for line in output_str.lines() { 28 | if let Some(name) = line.split_whitespace().nth(8) { 29 | workspaces.push(name.to_string()); 30 | } 31 | } 32 | } 33 | 34 | workspaces 35 | } 36 | 37 | /// 将窗口设置为在所有工作区可见 38 | pub fn make_visible_on_all_workspaces(window_id: &str) -> bool { 39 | if let Ok(output) = Command::new("wmctrl") 40 | .args(["-r", window_id, "-b", "add,sticky"]) 41 | .output() 42 | { 43 | return output.status.success(); 44 | } 45 | 46 | false 47 | } 48 | 49 | /// 检测窗口透明度/合成器支持 50 | pub fn has_compositing_support() -> bool { 51 | let desktop = Self::detect_desktop_environment(); 52 | 53 | // GNOME和KDE通常支持合成 54 | if desktop.contains("GNOME") || desktop.contains("KDE") { 55 | return true; 56 | } 57 | 58 | // 检查是否有Compositor运行 59 | if let Ok(output) = Command::new("ps").args(["aux"]).output() { 60 | let output_str = String::from_utf8_lossy(&output.stdout); 61 | return output_str.contains("compton") 62 | || output_str.contains("picom") 63 | || output_str.contains("compiz") 64 | || output_str.contains("mutter") 65 | || output_str.contains("kwin"); 66 | } 67 | 68 | false 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ); 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean; 47 | }) { 48 | const Comp = asChild ? Slot : "button"; 49 | 50 | return ( 51 | 56 | ); 57 | } 58 | 59 | export { Button, buttonVariants }; 60 | -------------------------------------------------------------------------------- /src/hooks/use-updater.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { check } from "@tauri-apps/plugin-updater"; 3 | import { relaunch } from "@tauri-apps/plugin-process"; 4 | import { toast } from "sonner"; 5 | 6 | export function useUpdaterCheck() { 7 | const [checking, setChecking] = useState(false); 8 | const [updateAvailable, setUpdateAvailable] = useState(false); 9 | const [updateInfo, setUpdateInfo] = useState<{ 10 | version: string; 11 | } | null>(null); 12 | 13 | const checkForUpdate = async () => { 14 | console.log("checking for update"); 15 | try { 16 | setChecking(true); 17 | 18 | const update = await check(); 19 | 20 | if (update && update.version) { 21 | console.log("update", update); 22 | 23 | console.log( 24 | `found update ${update.version} from ${update.date} with notes ${update.body}` 25 | ); 26 | setUpdateAvailable(true); 27 | setUpdateInfo({ 28 | version: update.version, 29 | }); 30 | } else { 31 | toast("当前已是最新版本"); 32 | } 33 | } catch (err) { 34 | toast.error("检查更新失败"); 35 | console.error(err instanceof Error ? err.message : "检查更新失败"); 36 | setUpdateAvailable(false); 37 | } finally { 38 | setChecking(false); 39 | } 40 | }; 41 | 42 | return { 43 | checking, 44 | updateAvailable, 45 | updateInfo, 46 | checkForUpdate, 47 | }; 48 | } 49 | 50 | export function useUpdaterDownload() { 51 | const [installing, setInstalling] = useState(false); 52 | const [progress, setProgress] = useState(0); 53 | 54 | const downloadAndInstall = async () => { 55 | try { 56 | setInstalling(true); 57 | setProgress(0); 58 | 59 | const update = await check(); 60 | if (!update) { 61 | throw new Error("没有可用的更新"); 62 | } 63 | 64 | let downloaded = 0; 65 | let contentLength = 0; 66 | 67 | await update.downloadAndInstall(async (event) => { 68 | console.log("download progress", event); 69 | 70 | switch (event.event) { 71 | case "Started": 72 | contentLength = event.data.contentLength as number; 73 | break; 74 | case "Progress": 75 | downloaded += event.data.chunkLength; 76 | const percentage = Math.round((downloaded / contentLength) * 100); 77 | setProgress(percentage); 78 | break; 79 | case "Finished": 80 | setProgress(100); 81 | break; 82 | } 83 | }); 84 | 85 | // 安装完成后重启应用 86 | await relaunch(); 87 | } catch (err) { 88 | toast.error("更新安装失败"); 89 | console.log(err); 90 | 91 | console.error(err instanceof Error ? err.message : "更新安装失败"); 92 | setProgress(0); 93 | } finally { 94 | setInstalling(false); 95 | } 96 | }; 97 | 98 | return { 99 | installing, 100 | progress, 101 | downloadAndInstall, 102 | }; 103 | } 104 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shui" 3 | version = "0.2.0" 4 | description = "一个专注喝水提醒的跨端桌面 App,关注打工人健康 💪 ,改善你的喝水习惯。" 5 | authors = ["Slash"] 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [lib] 11 | # The `_lib` suffix may seem redundant but it is necessary 12 | # to make the lib name unique and wouldn't conflict with the bin name. 13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 14 | name = "shui_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2", features = [] } 19 | 20 | [dependencies] 21 | tokio = { version = "1", features = ["full"] } 22 | lazy_static = "1.4.0" 23 | parking_lot = "0.12" 24 | tauri = { version = "2", features = [ "tray-icon", "macos-private-api", "image-ico" ] } 25 | serde = { workspace = true, features = ["derive"] } 26 | serde_json.workspace = true 27 | tauri-plugin-single-instance = "2" 28 | tauri-plugin-autostart = "2" 29 | tauri-plugin-sql = { version = "2", features = ["sqlite"] } 30 | tauri-plugin-log = "2" 31 | tauri-plugin-global-shortcut = "2" 32 | tauri-plugin-os = "2" 33 | tauri-plugin-dialog = "2" 34 | tauri-plugin-fs = "2" 35 | tauri-plugin-updater = "2" 36 | tauri-plugin-process = "2" 37 | tauri-plugin-drag = "2" 38 | tauri-plugin-macos-permissions = "2" 39 | tauri-plugin-store = "2" 40 | tauri-plugin-opener = "2" 41 | tauri-plugin-notification = "2" 42 | chrono = { version = "0.4", features = ["serde"] } 43 | tauri-plugin-clipboard-manager = "2" 44 | scopeguard = "1.2" 45 | winapi = { version = "0.3", features = [ "wtsapi32", "winuser", "winbase", "libloaderapi", "winnt", "powersetting"] } 46 | widestring = "1.0" # 用于处理Windows宽字符 47 | # tauri-plugin-fs-pro.workspace = true 48 | # tauri-plugin-eco-window.workspace = true 49 | # tauri-plugin-eco-locale.workspace = true 50 | # tauri-plugin-eco-clipboard.workspace = true 51 | # tauri-plugin-eco-ocr.workspace = true 52 | # tauri-plugin-eco-paste.workspace = true 53 | # tauri-plugin-eco-autostart.workspace = true 54 | 55 | # [target."cfg(target_os = \"macos\")".dependencies] 56 | 57 | [target.'cfg(target_os = "macos")'.dependencies] 58 | tauri-nspanel.workspace = true 59 | core-foundation = "0.9.4" 60 | core-graphics = "0.23.1" 61 | objc = "0.2.7" # Objective-C 运行时绑定 62 | objc-foundation = "0.1" # Foundation 框架支持 63 | 64 | [dependencies.windows] 65 | version = "0.48" 66 | features = [ 67 | "Win32_Foundation", 68 | "Win32_UI_WindowsAndMessaging", 69 | "Win32_System_Threading", 70 | "Win32_System_ProcessStatus", 71 | "Win32_System_Registry", 72 | "Win32_System_LibraryLoader", 73 | "Win32_System_RemoteDesktop", 74 | "Win32_System_Power", # 添加这一行 75 | ] 76 | 77 | [target.'cfg(target_os = "linux")'.dependencies] 78 | dbus = "0.9.7" 79 | notify-rust = "4.9" 80 | x11-dl = "2.21.0" 81 | gtk = "0.18.1" 82 | 83 | [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 84 | tauri-plugin-autostart = "2" 85 | tauri-plugin-global-shortcut = "2" 86 | tauri-plugin-updater = "2" 87 | 88 | [features] 89 | cargo-clippy = [] 90 | -------------------------------------------------------------------------------- /src-tauri/src/core/util/macos.rs: -------------------------------------------------------------------------------- 1 | use objc::runtime::Object; 2 | use objc::{class, msg_send, sel, sel_impl}; 3 | use objc_foundation::{INSString, NSString}; 4 | 5 | pub fn check_whitelist(whitelist_apps: &Vec) -> bool { 6 | #[cfg(target_os = "macos")] 7 | { 8 | unsafe { 9 | let workspace: *mut Object = msg_send![class!(NSWorkspace), sharedWorkspace]; 10 | if !workspace.is_null() { 11 | let app: *mut Object = msg_send![workspace, frontmostApplication]; 12 | if !app.is_null() { 13 | let url: *mut Object = msg_send![app, bundleURL]; 14 | let name = get_mditem_display_name_by_url(url); 15 | if let Some(name) = name { 16 | return whitelist_apps.contains(&name.trim_end_matches(".app").to_string()); 17 | } 18 | } 19 | } 20 | } 21 | } 22 | false 23 | } 24 | 25 | pub async fn get_local_installed_apps() -> Vec { 26 | let mut files = tokio::fs::read_dir("/Applications") 27 | .await 28 | .expect("failed to read directory"); 29 | 30 | let self_path = get_self_bundle_path(); 31 | 32 | let mut display_names = vec![]; 33 | while let Ok(Some(entry)) = files.next_entry().await { 34 | let path = entry.path(); 35 | let app = path 36 | .file_name() 37 | .expect("failed to get file name") 38 | .to_string_lossy(); 39 | if !app.ends_with(".app") { 40 | continue; 41 | } 42 | 43 | let path_str = path.to_string_lossy(); 44 | if path_str == self_path { 45 | continue; 46 | } 47 | 48 | if let Some(display_name) = get_mditem_display_name_by_path(&path_str) { 49 | display_names.push(display_name.trim_end_matches(".app").to_string()); 50 | } else { 51 | display_names.push(app.trim_end_matches(".app").to_string()); 52 | } 53 | } 54 | 55 | display_names.sort(); 56 | display_names 57 | } 58 | 59 | pub fn get_mditem_display_name_by_path(path: &str) -> Option { 60 | unsafe { 61 | let path = NSString::from_str(path); 62 | let url: *mut Object = msg_send![class!(NSURL), fileURLWithPath: path]; 63 | get_mditem_display_name_by_url(url) 64 | } 65 | } 66 | 67 | pub fn get_self_bundle_path() -> String { 68 | unsafe { 69 | let bundle: *mut Object = msg_send![class!(NSBundle), mainBundle]; 70 | let url: *mut Object = msg_send![bundle, bundlePath]; 71 | let ns_string: &NSString = &*(url as *const NSString); 72 | ns_string.as_str().to_string() 73 | } 74 | } 75 | 76 | pub unsafe fn get_mditem_display_name_by_url(url: *const Object) -> Option { 77 | let cls = class!(NSMetadataItem); 78 | let alloc: *mut Object = msg_send![cls, alloc]; 79 | let item: *mut Object = msg_send![alloc, initWithURL:url]; 80 | let name: *mut Object = 81 | msg_send![item, valueForAttribute: NSString::from_str("kMDItemDisplayName")]; 82 | 83 | if !name.is_null() { 84 | let ns_string: &NSString = &*(name as *const NSString); 85 | return Some(ns_string.as_str().to_string()); 86 | } 87 | 88 | None 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shui - 喝水提醒助手 2 | 3 |

4 | Shui Screenshot 5 |
6 |

7 | 8 | 一个专注喝水提醒的跨端桌面 App,关注打工人健康 💪 ,改善你的喝水习惯。 9 | 10 | ## ✨ 主要特性 11 | 12 | - 🎯 每日饮水目标设定 13 | - 🖥️ 全屏提醒 - 优雅且不可忽视的休息提示 14 | - ⏰ 智能时间管理 15 | - 自定义提醒间隔 16 | - 工作日智能提醒 17 | - 自定义时间范围 18 | - 🔔 多样化提醒方式 19 | - 全屏通知页面 20 | - 系统原生通知 21 | - 托盘实时倒计时 22 | - 目标完成音效提醒 23 | - 💡 智能且人性化 24 | - 自动识别工作日 25 | - 息屏、锁屏自动暂停 26 | - 托盘快捷操作 27 | - 应用白名单(默认:腾讯会议, Zoom, Google Meet, Microsoft Teams) 28 | - 📊 数据统计 29 | - 每日饮水量统计 30 | - 饮水习惯分析 31 | - 休息提醒统计 32 | - 数据可视化展示 33 | 34 | ## 🖥 应用界面 35 | 36 |

37 | Settings 38 |
39 | Notification 40 |

41 | 42 | ## 🚀 开始使用 43 | 44 | ### Platform Support 45 | 46 | - ✅ macOS 47 | - ✅ Windows 48 | - ✅ Linux 49 | - 🚧 Android (coming soon) 50 | 51 | ### 下载安装 52 | 53 | 从 [Releases](https://github.com/rock-zhang/Shui/releases/) 页面下载最新版本。 54 | 55 | #### macOS 56 | 57 | - Apple Silicon:下载 `Shui_x.x.x_aarch64.dmg` 58 | - Intel Chip:下载 `Shui_x.x.x_x64.dmg` 59 | 60 | #### Windows 61 | 62 | - 64 位系统:下载 `Shui_x.x.x_x64-setup.exe` 63 | - 32 位系统:下载 `Shui_x.x.x_x86-setup.exe` 64 | - ARM64 架构:下载 `Shui_x.x.x_arm64-setup.exe` 65 | 66 | #### Linux 67 | 68 | - x86_64 架构:下载 `Shui_x.x.x_amd64.deb` 69 | - ARM64 架构:下载 `Shui_x.x.x_arm64.deb` 70 | 71 | #### 注意 72 | 73 | 74 | 75 | `macOS`下如果遇到`"Shui"已损坏,无法打开`的提示,请在终端运行 76 | 77 | ```shell 78 | sudo xattr -r -d com.apple.quarantine /Applications/Shui.app 79 | ``` 80 | 81 | ## 🛣 开发路线 82 | 83 | ### 已实现功能 84 | 85 | - [x] 基础提醒功能 86 | - [x] 自定义提醒间隔 87 | - [x] 工作日智能提醒 88 | - [x] 系统托盘支持 89 | - [x] 全局快捷键 90 | - [x] 应用白名单管理 91 | - [x] 息屏、锁屏自动暂停 92 | - [x] 托盘快捷操作 93 | - [x] 自定义时间范围 94 | - [x] 系统原生通知 95 | - [x] 托盘实时倒计时 96 | 97 | ### 开发计划 98 | 99 | - [x] Windows 适配 100 | - [x] Linux 适配 101 | - [x] 提醒音效 102 | - [ ] 多语言支持 103 | - [ ] 数据统计与分析 104 | - [ ] 饮水量趋势图表 105 | - [ ] 休息时间统计 106 | - [ ] 数据导出功能 107 | - [ ] 饮水时间分布 108 | - [ ] 饮水时间间隔分析 109 | - [ ] 自定义主题 110 | 111 | ## 🛠 技术栈 112 | 113 | - [Tauri](https://tauri.app/) - 跨平台桌面应用框架 114 | - [Next.js](https://nextjs.org/) - React 应用框架 115 | - [React](https://reactjs.org/) - 用户界面框架 116 | - [Rust](https://www.rust-lang.org/) - 后端逻辑实现 117 | - [shadcn/ui](https://ui.shadcn.com/) - UI 组件库 118 | 119 | ## 社区交流 120 | 121 | 欢迎 PR 和 Issue,一起探讨和改进 Shui! 122 | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/rock-zhang/Shui) 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | ## ☕ 支持 131 | 132 | 如果你觉得本项目帮助到了你,请给作者一个免费的 Star,感谢你的支持! 133 | 134 | ## Star History 135 | 136 | 137 | 138 | 139 | 140 | Star History Chart 141 | 142 | 143 | -------------------------------------------------------------------------------- /src-tauri/src/core/setup/linux.rs: -------------------------------------------------------------------------------- 1 | use crate::timer::IS_RUNNING; 2 | use std::sync::atomic::Ordering; 3 | use std::thread; 4 | use std::time::Duration; 5 | use std::process::Command; 6 | 7 | #[cfg(target_os = "linux")] 8 | pub fn monitor_lock_screen() { 9 | // 开启一个线程来检测锁屏状态 10 | thread::spawn(|| { 11 | let mut previous_lock_state = false; 12 | 13 | // 尝试多种方法来检测锁屏状态 14 | loop { 15 | // 方法1: DBus方式检测GNOME/KDE等主流桌面环境 16 | let lock_state = check_dbus_lock_status().unwrap_or_else(|| { 17 | // 方法2: 使用命令行工具检测 18 | check_cmd_lock_status() 19 | }); 20 | 21 | // 状态变化时更新计时器 22 | if previous_lock_state != lock_state { 23 | previous_lock_state = lock_state; 24 | IS_RUNNING.store(!lock_state, Ordering::SeqCst); 25 | 26 | let (status, action) = if lock_state { 27 | ("锁屏", "停止") 28 | } else { 29 | ("解锁", "开始") 30 | }; 31 | println!("系统{},{}计时", status, action); 32 | } 33 | 34 | thread::sleep(Duration::from_secs(1)); 35 | } 36 | }); 37 | } 38 | 39 | fn check_dbus_lock_status() -> Option { 40 | use dbus::blocking::Connection; 41 | 42 | // 尝试多种DBus接口,以适应不同的桌面环境 43 | let interfaces = [ 44 | // GNOME ScreenSaver 45 | ("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", "org.freedesktop.ScreenSaver", "GetActive"), 46 | // KDE/Plasma 47 | ("org.freedesktop.ScreenSaver", "/ScreenSaver", "org.freedesktop.ScreenSaver", "GetActive"), 48 | // GNOME Shell 49 | ("org.gnome.SessionManager", "/org/gnome/SessionManager", "org.gnome.SessionManager", "IsScreenLocked"), 50 | // Unity/Cinnamon 51 | ("org.cinnamon.ScreenSaver", "/org/cinnamon/ScreenSaver", "org.cinnamon.ScreenSaver", "GetActive"), 52 | ]; 53 | 54 | if let Ok(conn) = Connection::new_session() { 55 | for (service, path, interface, method) in interfaces { 56 | let proxy = conn.with_proxy(service, path, Duration::from_millis(1000)); 57 | 58 | let result: Result<(bool,), _> = proxy.method_call(interface, method, ()); 59 | 60 | if let Ok((is_locked,)) = result { 61 | return Some(is_locked); 62 | } 63 | } 64 | } 65 | 66 | None 67 | } 68 | 69 | fn check_cmd_lock_status() -> bool { 70 | // 检查是否有锁屏进程在运行 71 | let lock_processes = [ 72 | "gnome-screensaver-dialog", 73 | "i3lock", 74 | "slock", 75 | "swaylock", 76 | "xscreensaver" 77 | ]; 78 | 79 | for process in lock_processes { 80 | if let Ok(output) = Command::new("pgrep") 81 | .arg(process) 82 | .output() 83 | { 84 | if !output.stdout.is_empty() { 85 | return true; 86 | } 87 | } 88 | } 89 | 90 | // 检查X11会话状态 91 | if let Ok(output) = Command::new("xscreensaver-command") 92 | .arg("-time") 93 | .output() 94 | { 95 | let output_str = String::from_utf8_lossy(&output.stdout); 96 | return output_str.contains("screen locked"); 97 | } 98 | 99 | false 100 | } 101 | 102 | #[cfg(not(target_os = "linux"))] 103 | pub fn monitor_lock_screen() { 104 | println!("当前系统不支持锁屏监控"); 105 | } 106 | -------------------------------------------------------------------------------- /src/hooks/use-tray.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from "react"; 2 | import { TrayIcon } from "@tauri-apps/api/tray"; 3 | import { 4 | Menu, 5 | MenuItem, 6 | PredefinedMenuItem, 7 | Submenu, 8 | } from "@tauri-apps/api/menu"; 9 | import { invoke } from "@tauri-apps/api/core"; 10 | import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; 11 | import { getVersion } from "@tauri-apps/api/app"; 12 | import { platform } from "@tauri-apps/plugin-os"; 13 | 14 | const TRAY_ID = "main-tray"; 15 | 16 | export function useTray() { 17 | useEffect(() => { 18 | setMenu(); 19 | }, []); 20 | 21 | const checkTauriAndInit = async () => { 22 | try { 23 | // 尝试获取 Tauri 版本,如果失败则说明不在 Tauri 环境 24 | await getVersion(); 25 | } catch (e) { 26 | console.log("非 Tauri 环境,跳过托盘初始化"); 27 | throw e; 28 | } 29 | }; 30 | 31 | const getMenu = useCallback(async () => { 32 | const menu = await Menu.new(); 33 | 34 | await menu.append( 35 | await MenuItem.new({ 36 | text: "偏好设置", 37 | action: async () => { 38 | // 尝试获取并显示主窗口 39 | const mainWindow = await WebviewWindow.getByLabel("main"); 40 | 41 | if (mainWindow) { 42 | // 尝试取消最小化(如果窗口被最小化了) 43 | const isMinimized = await mainWindow.isMinimized(); 44 | if (isMinimized) await mainWindow.unminimize(); 45 | 46 | // 显示窗口 47 | await mainWindow.show(); 48 | await mainWindow.setFocus(); 49 | } 50 | }, 51 | }) 52 | ); 53 | await menu.append(await PredefinedMenuItem.new({ item: "Separator" })); 54 | await menu.append( 55 | await MenuItem.new({ 56 | text: "立即休息", 57 | action: async () => { 58 | invoke("call_reminder"); 59 | }, 60 | }) 61 | ); 62 | 63 | // 创建子菜单 64 | const submenu = await Submenu.new({ 65 | text: "计时控制", 66 | items: [ 67 | { 68 | text: "暂停计时", 69 | action: async () => { 70 | invoke("pause_timer"); 71 | }, 72 | }, 73 | { 74 | text: "重新计时", 75 | action: async () => { 76 | invoke("start_timer"); 77 | }, 78 | }, 79 | ], 80 | }); 81 | await menu.append(submenu); 82 | 83 | await menu.append(await PredefinedMenuItem.new({ item: "Separator" })); 84 | 85 | // 根据平台选择不同的退出菜单实现 86 | const currentPlatform = await platform(); 87 | if (currentPlatform === "linux") { 88 | // Linux 系统使用普通的 MenuItem 89 | await menu.append( 90 | await MenuItem.new({ 91 | text: "退出", 92 | action: async () => { 93 | console.log("退出应用"); 94 | invoke("quit"); 95 | }, 96 | }) 97 | ); 98 | } else { 99 | // 其他平台使用 PredefinedMenuItem 100 | await menu.append( 101 | await PredefinedMenuItem.new({ text: "退出", item: "Quit" }) 102 | ); 103 | } 104 | 105 | return menu; 106 | }, []); 107 | 108 | const setMenu = useCallback(async () => { 109 | let trayInstance: TrayIcon | null = null; 110 | 111 | try { 112 | await checkTauriAndInit(); 113 | 114 | // 检查是否已存在托盘实例 115 | trayInstance = await TrayIcon.getById(TRAY_ID); 116 | console.log("trayInstance", trayInstance); 117 | 118 | trayInstance?.setMenu(await getMenu()); 119 | // trayInstance?.setIconAsTemplate(true); 120 | } catch (error) { 121 | console.error("创建托盘失败:", error); 122 | } 123 | }, [getMenu]); 124 | } 125 | -------------------------------------------------------------------------------- /src-tauri/src/core/util/linux.rs: -------------------------------------------------------------------------------- 1 | use dbus::blocking::Connection; 2 | use std::process::Command; 3 | use tauri::{WebviewUrl, WebviewWindowBuilder, Manager}; 4 | 5 | pub fn check_whitelist(whitelist_apps: &Vec) -> bool { 6 | // 使用xdotool获取当前活动窗口的应用信息 7 | if let Ok(output) = Command::new("xdotool") 8 | .args(["getactivewindow", "getwindowname"]) 9 | .output() 10 | { 11 | let window_title = String::from_utf8_lossy(&output.stdout).trim().to_string(); 12 | 13 | // 尝试获取当前窗口的WM_CLASS 14 | if let Ok(class_output) = Command::new("sh") 15 | .arg("-c") 16 | .arg("xprop -id $(xdotool getactivewindow) WM_CLASS") 17 | .output() 18 | { 19 | let class_output_str = String::from_utf8_lossy(&class_output.stdout); 20 | if let Some(app_name) = class_output_str 21 | .split('"') 22 | .nth(3) 23 | .map(|s| s.to_string()) 24 | { 25 | println!("当前活动应用: {}", app_name); 26 | return whitelist_apps.contains(&app_name); 27 | } 28 | } 29 | 30 | // 尝试根据窗口标题匹配应用 31 | for app in whitelist_apps { 32 | if window_title.contains(app) { 33 | return true; 34 | } 35 | } 36 | } 37 | 38 | false 39 | } 40 | 41 | pub fn get_local_installed_apps(app_handle: &tauri::AppHandle) -> Vec { 42 | let mut apps = Vec::new(); 43 | let self_name = app_handle.package_info().name.clone(); 44 | 45 | // 获取用户主目录 46 | let home_dir = std::env::var("HOME").unwrap_or_else(|_| "/home".to_string()); 47 | 48 | // 系统应用目录 49 | let paths = vec![ 50 | "/usr/share/applications".to_string(), 51 | "/usr/local/share/applications".to_string(), 52 | format!("{}/.local/share/applications", home_dir), 53 | // Flatpak应用目录 54 | "/var/lib/flatpak/exports/share/applications".to_string(), 55 | format!("{}/.local/share/flatpak/exports/share/applications", home_dir), 56 | ]; 57 | 58 | // 处理.desktop文件 59 | for path in paths { 60 | if let Ok(entries) = std::fs::read_dir(&path) { 61 | for entry in entries.filter_map(Result::ok) { 62 | if let Some(file_name) = entry.file_name().to_str() { 63 | if file_name.ends_with(".desktop") && !file_name.starts_with(&format!("{}.desktop", self_name)) { 64 | // 读取.desktop文件获取应用名称 65 | if let Ok(content) = std::fs::read_to_string(entry.path()) { 66 | if let Some(name) = content 67 | .lines() 68 | .find(|line| line.starts_with("Name=")) 69 | .and_then(|line| line.strip_prefix("Name=")) 70 | { 71 | apps.push(name.to_string()); 72 | } 73 | } 74 | } 75 | } 76 | } 77 | } 78 | } 79 | 80 | // 添加Snap应用 81 | if let Ok(output) = Command::new("snap").args(["list"]).output() { 82 | let output_str = String::from_utf8_lossy(&output.stdout); 83 | for line in output_str.lines().skip(1) { // 跳过标题行 84 | if let Some(app_name) = line.split_whitespace().next() { 85 | if app_name != self_name { 86 | apps.push(app_name.to_string()); 87 | } 88 | } 89 | } 90 | } 91 | 92 | apps.sort(); 93 | apps.dedup(); 94 | apps 95 | } 96 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { XIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Dialog({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function DialogTrigger({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return 19 | } 20 | 21 | function DialogPortal({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return 25 | } 26 | 27 | function DialogClose({ 28 | ...props 29 | }: React.ComponentProps) { 30 | return 31 | } 32 | 33 | function DialogOverlay({ 34 | className, 35 | ...props 36 | }: React.ComponentProps) { 37 | return ( 38 | 46 | ) 47 | } 48 | 49 | function DialogContent({ 50 | className, 51 | children, 52 | ...props 53 | }: React.ComponentProps) { 54 | return ( 55 | 56 | 57 | 65 | {children} 66 | 67 | 68 | Close 69 | 70 | 71 | 72 | ) 73 | } 74 | 75 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { 76 | return ( 77 |
82 | ) 83 | } 84 | 85 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { 86 | return ( 87 |
95 | ) 96 | } 97 | 98 | function DialogTitle({ 99 | className, 100 | ...props 101 | }: React.ComponentProps) { 102 | return ( 103 | 108 | ) 109 | } 110 | 111 | function DialogDescription({ 112 | className, 113 | ...props 114 | }: React.ComponentProps) { 115 | return ( 116 | 121 | ) 122 | } 123 | 124 | export { 125 | Dialog, 126 | DialogClose, 127 | DialogContent, 128 | DialogDescription, 129 | DialogFooter, 130 | DialogHeader, 131 | DialogOverlay, 132 | DialogPortal, 133 | DialogTitle, 134 | DialogTrigger, 135 | } 136 | -------------------------------------------------------------------------------- /src/app/setting/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Switch } from "@/components/ui/switch"; 4 | import { useEffect, useState } from "react"; 5 | import { load } from "@tauri-apps/plugin-store"; 6 | import { useTray } from "@/hooks/use-tray"; 7 | import { enable, isEnabled, disable } from "@tauri-apps/plugin-autostart"; 8 | import { invoke } from "@tauri-apps/api/core"; 9 | import { STORE_NAME } from "@/lib/constants"; 10 | import { usePlatform } from "@/hooks/use-platform"; 11 | import { getGeneralConfig } from "@/utils/store"; 12 | 13 | export default function Home() { 14 | const [config, setConfig] = useState({ 15 | isAutoStart: false, 16 | isCountDown: false, 17 | isFullScreen: false, // 新增全屏提醒选项 18 | }); 19 | const { isWindows } = usePlatform(); 20 | useTray(); 21 | 22 | useEffect(() => { 23 | async function loadConfig() { 24 | const [generalSetting, isAutoStart] = await Promise.all([ 25 | getGeneralConfig(), 26 | isEnabled(), 27 | ]); 28 | 29 | setConfig({ 30 | ...config, 31 | isCountDown: generalSetting?.isCountDown || false, 32 | isFullScreen: generalSetting?.isFullScreen || false, // 设置默认值 33 | isAutoStart, 34 | }); 35 | } 36 | 37 | loadConfig(); 38 | }, []); 39 | 40 | const saveConfig = async (filed: string, checked: boolean) => { 41 | const store = await load(STORE_NAME.config, { autoSave: false }); 42 | const oldConfig = await store.get<{ value: number }>("general"); 43 | 44 | setConfig({ 45 | ...config, 46 | [filed]: checked, 47 | }); 48 | 49 | await store.set("general", { 50 | ...oldConfig, 51 | [filed]: checked, 52 | }); 53 | await store.save(); 54 | }; 55 | 56 | const handleAutoStartChange = async (checked: boolean) => { 57 | saveConfig("isAutoStart", checked); 58 | 59 | if (checked) { 60 | enable(); 61 | console.log("isAutoStart", await isEnabled()); 62 | } else { 63 | disable(); 64 | console.log("isAutoStart", await isEnabled()); 65 | } 66 | }; 67 | 68 | return ( 69 |
70 |

通用

71 | 72 |
73 |
74 | 77 |

81 | 电脑重启之后自动开始倒计时 82 |

83 |
84 | 88 |
89 | 90 |
91 |
92 | 95 |

99 | 开启后将在菜单栏显示倒计时,支持macOS和linux 100 |

101 |
102 | { 106 | await saveConfig("isCountDown", checked); 107 | // 重置计时器 108 | invoke("reset_timer"); 109 | }} 110 | /> 111 |
112 | 113 |
114 |
115 | 118 |

122 | 开启后将以全屏方式显示提醒,关闭则使用系统通知 123 |

124 |
125 | { 128 | await saveConfig("isFullScreen", checked); 129 | }} 130 | /> 131 |
132 |
133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /README.en.md: -------------------------------------------------------------------------------- 1 | # Shui - Water Reminder Assistant 2 | 3 |

4 | Shui Screenshot 5 |
6 |

7 | 8 | A cross-platform desktop app focused on water intake reminders, promoting office workers' health 💪 and improving your drinking habits. 9 | 10 | ## ✨ Key Features 11 | 12 | - 🎯 Daily Water Intake Goals 13 | - 🖥️ Full-screen Reminders - Elegant and Unmissable Break Notifications 14 | - ⏰ Smart Time Management 15 | - Customizable Reminder Intervals 16 | - Smart Workday Reminders 17 | - Custom Time Range 18 | - 🔔 Diverse Notification Methods 19 | - Full-screen Notification Page 20 | - Native System Notifications 21 | - Tray Real-time Countdown 22 | - Goal Completion Sound Effects 23 | - 💡 Smart and User-friendly 24 | - Automatic Workday Recognition 25 | - Auto-pause on Screen Lock/Sleep 26 | - Tray Quick Actions 27 | - App Whitelist (Default: Tencent Meeting, Zoom, Google Meet, Microsoft Teams) 28 | - 📊 Data Statistics 29 | - Daily Water Intake Statistics 30 | - Drinking Habit Analysis 31 | - Break Reminder Statistics 32 | - Data Visualization 33 | 34 | ## 🖥 Application Interface 35 | 36 |

37 | Settings 38 |
39 | Notification 40 |

41 | 42 | ## 🚀 Getting Started 43 | 44 | ### Platform Support 45 | 46 | - ✅ macOS 47 | - ✅ Windows 48 | - 🚧 Linux (coming soon) 49 | - 🚧 Android (coming soon) 50 | 51 | ### Download and Installation 52 | 53 | Download the latest version from the [Releases](https://github.com/rock-zhang/Shui/releases/) page. 54 | 55 | #### macOS 56 | 57 | - Apple Silicon: Download `Shui_x.x.x_aarch64.dmg` 58 | - Intel Chip: Download `Shui_x.x.x_x64.dmg` 59 | 60 | #### Windows 61 | 62 | - 64-bit System: Download `Shui_x.x.x_x64-setup.exe` 63 | - 32-bit System: Download `Shui_x.x.x_x86-setup.exe` 64 | - ARM64 Architecture: Download `Shui_x.x.x_arm64-setup.exe` 65 | 66 | #### Note 67 | 68 | 69 | 70 | If you encounter the "Shui is damaged and can't be opened" message on `macOS`, please run the following command in Terminal: 71 | 72 | ```shell 73 | sudo xattr -r -d com.apple.quarantine /Applications/Shui.app 74 | ``` 75 | 76 | ## 🛣 Development Roadmap 77 | 78 | ### Implemented Features 79 | 80 | - [x] Basic Reminder Functionality 81 | - [x] Customizable Reminder Intervals 82 | - [x] Smart Workday Reminders 83 | - [x] System Tray Support 84 | - [x] Global Hotkeys 85 | - [x] App Whitelist Management 86 | - [x] Auto-pause on Screen Lock/Sleep 87 | - [x] Tray Quick Actions 88 | - [x] Custom Time Range 89 | - [x] Native System Notifications 90 | - [x] Tray Real-time Countdown 91 | 92 | ### Development Plans 93 | 94 | - [x] Windows Support 95 | - [ ] Multi-language Support 96 | - [ ] Linux Support 97 | - [x] Reminder Sound Effects 98 | - [ ] Data Statistics and Analysis 99 | - [ ] Water Intake Trend Charts 100 | - [ ] Break Time Statistics 101 | - [ ] Data Export Functionality 102 | - [ ] Water Intake Time Distribution 103 | - [ ] Water Intake Interval Analysis 104 | - [ ] Custom Themes 105 | 106 | ## 🛠 Tech Stack 107 | 108 | - [Tauri](https://tauri.app/) - Cross-platform Desktop App Framework 109 | - [Next.js](https://nextjs.org/) - React Application Framework 110 | - [React](https://reactjs.org/) - User Interface Framework 111 | - [Rust](https://www.rust-lang.org/) - Backend Logic Implementation 112 | - [shadcn/ui](https://ui.shadcn.com/) - UI Component Library 113 | 114 | ## Community 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | ## ☕ Support 123 | 124 | If you find this project helpful, please give the author a free Star. Thank you for your support! 125 | 126 | ## Star History 127 | 128 | 129 | 130 | 131 | 132 | Star History Chart 133 | 134 | 135 | -------------------------------------------------------------------------------- /src/components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SheetPrimitive from "@radix-ui/react-dialog" 5 | import { XIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Sheet({ ...props }: React.ComponentProps) { 10 | return 11 | } 12 | 13 | function SheetTrigger({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return 17 | } 18 | 19 | function SheetClose({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return 23 | } 24 | 25 | function SheetPortal({ 26 | ...props 27 | }: React.ComponentProps) { 28 | return 29 | } 30 | 31 | function SheetOverlay({ 32 | className, 33 | ...props 34 | }: React.ComponentProps) { 35 | return ( 36 | 44 | ) 45 | } 46 | 47 | function SheetContent({ 48 | className, 49 | children, 50 | side = "right", 51 | ...props 52 | }: React.ComponentProps & { 53 | side?: "top" | "right" | "bottom" | "left" 54 | }) { 55 | return ( 56 | 57 | 58 | 74 | {children} 75 | 76 | 77 | Close 78 | 79 | 80 | 81 | ) 82 | } 83 | 84 | function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { 85 | return ( 86 |
91 | ) 92 | } 93 | 94 | function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { 95 | return ( 96 |
101 | ) 102 | } 103 | 104 | function SheetTitle({ 105 | className, 106 | ...props 107 | }: React.ComponentProps) { 108 | return ( 109 | 114 | ) 115 | } 116 | 117 | function SheetDescription({ 118 | className, 119 | ...props 120 | }: React.ComponentProps) { 121 | return ( 122 | 127 | ) 128 | } 129 | 130 | export { 131 | Sheet, 132 | SheetTrigger, 133 | SheetClose, 134 | SheetContent, 135 | SheetHeader, 136 | SheetFooter, 137 | SheetTitle, 138 | SheetDescription, 139 | } 140 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --color-background: var(--background); 8 | --color-foreground: var(--foreground); 9 | --font-sans: var(--font-geist-sans); 10 | --font-mono: var(--font-geist-mono); 11 | --color-sidebar-ring: var(--sidebar-ring); 12 | --color-sidebar-border: var(--sidebar-border); 13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 14 | --color-sidebar-accent: var(--sidebar-accent); 15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 16 | --color-sidebar-primary: var(--sidebar-primary); 17 | --color-sidebar-foreground: var(--sidebar-foreground); 18 | --color-sidebar: var(--sidebar); 19 | --color-chart-5: var(--chart-5); 20 | --color-chart-4: var(--chart-4); 21 | --color-chart-3: var(--chart-3); 22 | --color-chart-2: var(--chart-2); 23 | --color-chart-1: var(--chart-1); 24 | --color-ring: var(--ring); 25 | --color-input: var(--input); 26 | --color-border: var(--border); 27 | --color-destructive: var(--destructive); 28 | --color-accent-foreground: var(--accent-foreground); 29 | --color-accent: var(--accent); 30 | --color-muted-foreground: var(--muted-foreground); 31 | --color-muted: var(--muted); 32 | --color-secondary-foreground: var(--secondary-foreground); 33 | --color-secondary: var(--secondary); 34 | --color-primary-foreground: var(--primary-foreground); 35 | --color-primary: var(--primary); 36 | --color-popover-foreground: var(--popover-foreground); 37 | --color-popover: var(--popover); 38 | --color-card-foreground: var(--card-foreground); 39 | --color-card: var(--card); 40 | --radius-sm: calc(var(--radius) - 4px); 41 | --radius-md: calc(var(--radius) - 2px); 42 | --radius-lg: var(--radius); 43 | --radius-xl: calc(var(--radius) + 4px); 44 | } 45 | 46 | :root { 47 | --radius: 0.625rem; 48 | --background: oklch(1 0 0); 49 | --foreground: oklch(0.129 0.042 264.695); 50 | --card: oklch(1 0 0); 51 | --card-foreground: oklch(0.129 0.042 264.695); 52 | --popover: oklch(1 0 0); 53 | --popover-foreground: oklch(0.129 0.042 264.695); 54 | --primary: oklch(0.208 0.042 265.755); 55 | --primary-foreground: oklch(0.984 0.003 247.858); 56 | --secondary: oklch(0.968 0.007 247.896); 57 | --secondary-foreground: oklch(0.208 0.042 265.755); 58 | --muted: oklch(0.968 0.007 247.896); 59 | --muted-foreground: oklch(0.554 0.046 257.417); 60 | --accent: oklch(0.968 0.007 247.896); 61 | --accent-foreground: oklch(0.208 0.042 265.755); 62 | --destructive: oklch(0.577 0.245 27.325); 63 | --border: oklch(0.929 0.013 255.508); 64 | --input: oklch(0.929 0.013 255.508); 65 | --ring: oklch(0.704 0.04 256.788); 66 | --chart-1: oklch(0.646 0.222 41.116); 67 | --chart-2: oklch(0.6 0.118 184.704); 68 | --chart-3: oklch(0.398 0.07 227.392); 69 | --chart-4: oklch(0.828 0.189 84.429); 70 | --chart-5: oklch(0.769 0.188 70.08); 71 | --sidebar: oklch(0.984 0.003 247.858); 72 | --sidebar-foreground: oklch(0.129 0.042 264.695); 73 | --sidebar-primary: oklch(0.208 0.042 265.755); 74 | --sidebar-primary-foreground: oklch(0.984 0.003 247.858); 75 | --sidebar-accent: oklch(0.968 0.007 247.896); 76 | --sidebar-accent-foreground: oklch(0.208 0.042 265.755); 77 | --sidebar-border: oklch(0.929 0.013 255.508); 78 | --sidebar-ring: oklch(0.704 0.04 256.788); 79 | } 80 | 81 | .dark { 82 | --background: oklch(0.129 0.042 264.695); 83 | --foreground: oklch(0.984 0.003 247.858); 84 | --card: oklch(0.208 0.042 265.755); 85 | --card-foreground: oklch(0.984 0.003 247.858); 86 | --popover: oklch(0.208 0.042 265.755); 87 | --popover-foreground: oklch(0.984 0.003 247.858); 88 | --primary: oklch(0.929 0.013 255.508); 89 | --primary-foreground: oklch(0.208 0.042 265.755); 90 | --secondary: oklch(0.279 0.041 260.031); 91 | --secondary-foreground: oklch(0.984 0.003 247.858); 92 | --muted: oklch(0.279 0.041 260.031); 93 | --muted-foreground: oklch(0.704 0.04 256.788); 94 | --accent: oklch(0.279 0.041 260.031); 95 | --accent-foreground: oklch(0.984 0.003 247.858); 96 | --destructive: oklch(0.704 0.191 22.216); 97 | --border: oklch(1 0 0 / 10%); 98 | --input: oklch(1 0 0 / 15%); 99 | --ring: oklch(0.551 0.027 264.364); 100 | --chart-1: oklch(0.488 0.243 264.376); 101 | --chart-2: oklch(0.696 0.17 162.48); 102 | --chart-3: oklch(0.769 0.188 70.08); 103 | --chart-4: oklch(0.627 0.265 303.9); 104 | --chart-5: oklch(0.645 0.246 16.439); 105 | --sidebar: oklch(0.208 0.042 265.755); 106 | --sidebar-foreground: oklch(0.984 0.003 247.858); 107 | --sidebar-primary: oklch(0.488 0.243 264.376); 108 | --sidebar-primary-foreground: oklch(0.984 0.003 247.858); 109 | --sidebar-accent: oklch(0.279 0.041 260.031); 110 | --sidebar-accent-foreground: oklch(0.984 0.003 247.858); 111 | --sidebar-border: oklch(1 0 0 / 10%); 112 | --sidebar-ring: oklch(0.551 0.027 264.364); 113 | } 114 | 115 | @layer base { 116 | * { 117 | @apply border-border outline-ring/50; 118 | } 119 | body { 120 | @apply bg-background text-foreground; 121 | -webkit-user-select: none; 122 | -moz-user-select: none; 123 | -ms-user-select: none; 124 | user-select: none; 125 | } 126 | } 127 | 128 | * { 129 | outline: none; 130 | } 131 | 132 | button:focus { 133 | outline: none; 134 | } 135 | -------------------------------------------------------------------------------- /src-tauri/src/core/window/windows.rs: -------------------------------------------------------------------------------- 1 | use tauri::{LogicalPosition, LogicalSize, Manager, WebviewUrl, WebviewWindowBuilder}; 2 | 3 | pub fn show_reminder(app_handle: &tauri::AppHandle) { 4 | println!("[windows] show_reminder"); 5 | 6 | // 优化检查逻辑,避免重复代码 7 | if let Ok(monitors) = app_handle.available_monitors() { 8 | let needs_create = !monitors.iter().enumerate().any(|(index, _)| { 9 | let reminder_label = format!("reminder_{}", index); 10 | app_handle.get_webview_window(&reminder_label).is_some() 11 | }); 12 | 13 | if needs_create { 14 | show_or_create_reminder_window(app_handle); 15 | } else { 16 | update_existing_windows(app_handle, &monitors); 17 | } 18 | } 19 | } 20 | 21 | // 新增函数:更新现有窗口 22 | fn update_existing_windows(app_handle: &tauri::AppHandle, monitors: &Vec) { 23 | for (index, monitor) in monitors.iter().enumerate() { 24 | let reminder_label = format!("reminder_{}", index); 25 | 26 | if let Some(window) = app_handle.get_webview_window(&reminder_label) { 27 | // 重新计算窗口度量,确保占满全屏 28 | let (scaled_width, scaled_height, scaled_position) = calculate_window_metrics(monitor); 29 | 30 | println!( 31 | "更新窗口 {}: 新尺寸=({:.0}, {:.0}), 新位置={:?}", 32 | reminder_label, scaled_width, scaled_height, scaled_position 33 | ); 34 | 35 | // 同时更新尺寸和位置,确保占满全屏 36 | let _ = window.set_size(LogicalSize::new(scaled_width, scaled_height)); 37 | let _ = window.set_position(LogicalPosition::new( 38 | scaled_position.x as f64, 39 | scaled_position.y as f64, 40 | )); 41 | let _ = window.show(); 42 | } 43 | } 44 | } 45 | 46 | fn show_or_create_reminder_window(app_handle: &tauri::AppHandle) { 47 | if let Ok(monitors) = app_handle.available_monitors() { 48 | for (index, monitor) in monitors.iter().enumerate() { 49 | let reminder_label = format!("reminder_{}", index); 50 | 51 | // 如果窗口已存在则显示 52 | if let Some(window) = app_handle.get_webview_window(&reminder_label) { 53 | let _ = window.show(); 54 | continue; 55 | } 56 | 57 | // 计算窗口尺寸和位置 58 | let (scaled_width, scaled_height, position) = calculate_window_metrics(monitor); 59 | 60 | println!( 61 | "Monitor {}: position={:?}, scale_factor={:?}, scaled_size=({:?}, {:?})", 62 | index, 63 | position, 64 | monitor.scale_factor(), 65 | scaled_width, 66 | scaled_height 67 | ); 68 | 69 | // 创建新窗口 70 | create_reminder_window( 71 | app_handle, 72 | &reminder_label, 73 | scaled_width, 74 | scaled_height, 75 | position, 76 | ); 77 | } 78 | } 79 | } 80 | 81 | // 新增函数:计算窗口度量,提供精确的缩放和舍入 82 | fn calculate_window_metrics(monitor: &tauri::Monitor) -> (f64, f64, tauri::PhysicalPosition) { 83 | let size = monitor.size(); 84 | let scale_factor = monitor.scale_factor(); 85 | let position = monitor.position(); 86 | 87 | // 添加舍入处理,确保像素对齐和更好的显示效果 88 | let scaled_width = (size.width as f64 / scale_factor).round(); 89 | let scaled_height = (size.height as f64 / scale_factor).round(); 90 | 91 | // 修复位置计算,考虑缩放因子 92 | let scaled_position = tauri::PhysicalPosition::new( 93 | ((position.x as f64 / scale_factor).round()) as i32, 94 | ((position.y as f64 / scale_factor).round()) as i32, 95 | ); 96 | 97 | (scaled_width, scaled_height, scaled_position) 98 | } 99 | 100 | // 新增函数:创建提醒窗口 101 | fn create_reminder_window( 102 | app_handle: &tauri::AppHandle, 103 | label: &str, 104 | width: f64, 105 | height: f64, 106 | position: tauri::PhysicalPosition, 107 | ) { 108 | println!("width xxxxx{:?}{:?}", width, height); 109 | println!("position xxxxx{:?}", position); 110 | let _ = WebviewWindowBuilder::new(app_handle, label, WebviewUrl::App("reminder/".into())) 111 | .decorations(false) 112 | .closable(false) 113 | .maximized(false) 114 | .transparent(true) 115 | .always_on_top(true) 116 | .fullscreen(true) 117 | .inner_size(width, height) 118 | .maximizable(false) 119 | .resizable(false) 120 | .minimizable(false) 121 | .position(position.x as f64, position.y as f64) 122 | .build() 123 | .expect(&format!("failed to create reminder window {}", label)); 124 | } 125 | 126 | pub fn hide_reminder(app_handle: &tauri::AppHandle) { 127 | if let Ok(monitors) = app_handle.available_monitors() { 128 | for (index, _) in monitors.iter().enumerate() { 129 | let reminder_label = format!("reminder_{}", index); 130 | if let Some(window) = app_handle.get_webview_window(&reminder_label) { 131 | let _ = window.hide(); 132 | } 133 | } 134 | } 135 | } 136 | 137 | pub fn hide_reminder_single(app_handle: &tauri::AppHandle, label: &str) { 138 | if let Some(window) = app_handle.get_webview_window(label) { 139 | let _ = window.hide(); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Command as CommandPrimitive } from "cmdk" 5 | import { SearchIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | import { 9 | Dialog, 10 | DialogContent, 11 | DialogDescription, 12 | DialogHeader, 13 | DialogTitle, 14 | } from "@/components/ui/dialog" 15 | 16 | function Command({ 17 | className, 18 | ...props 19 | }: React.ComponentProps) { 20 | return ( 21 | 29 | ) 30 | } 31 | 32 | function CommandDialog({ 33 | title = "Command Palette", 34 | description = "Search for a command to run...", 35 | children, 36 | ...props 37 | }: React.ComponentProps & { 38 | title?: string 39 | description?: string 40 | }) { 41 | return ( 42 | 43 | 44 | {title} 45 | {description} 46 | 47 | 48 | 49 | {children} 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | function CommandInput({ 57 | className, 58 | ...props 59 | }: React.ComponentProps) { 60 | return ( 61 |
65 | 66 | 74 |
75 | ) 76 | } 77 | 78 | function CommandList({ 79 | className, 80 | ...props 81 | }: React.ComponentProps) { 82 | return ( 83 | 91 | ) 92 | } 93 | 94 | function CommandEmpty({ 95 | ...props 96 | }: React.ComponentProps) { 97 | return ( 98 | 103 | ) 104 | } 105 | 106 | function CommandGroup({ 107 | className, 108 | ...props 109 | }: React.ComponentProps) { 110 | return ( 111 | 119 | ) 120 | } 121 | 122 | function CommandSeparator({ 123 | className, 124 | ...props 125 | }: React.ComponentProps) { 126 | return ( 127 | 132 | ) 133 | } 134 | 135 | function CommandItem({ 136 | className, 137 | ...props 138 | }: React.ComponentProps) { 139 | return ( 140 | 148 | ) 149 | } 150 | 151 | function CommandShortcut({ 152 | className, 153 | ...props 154 | }: React.ComponentProps<"span">) { 155 | return ( 156 | 164 | ) 165 | } 166 | 167 | export { 168 | Command, 169 | CommandDialog, 170 | CommandInput, 171 | CommandList, 172 | CommandEmpty, 173 | CommandGroup, 174 | CommandItem, 175 | CommandShortcut, 176 | CommandSeparator, 177 | } 178 | -------------------------------------------------------------------------------- /src-tauri/src/commands.rs: -------------------------------------------------------------------------------- 1 | use crate::core::window; 2 | use crate::core::{store::settings::AppSettings, util}; 3 | use crate::timer; 4 | use serde::Serialize; 5 | use std::sync::atomic::Ordering; 6 | 7 | // Remove this line since we don't need it 8 | // use tauri::api::version::Version; 9 | use tauri::{Emitter, Manager}; 10 | use timer::IS_RUNNING; 11 | use tokio::time::{sleep, Duration}; 12 | 13 | use std::sync::Mutex; 14 | use tokio::sync::mpsc; 15 | 16 | // 只保留 channel 相关的静态变量 17 | static REMINDER_PAGE_COUNTDOWN_SENDER: Mutex>> = Mutex::new(None); 18 | 19 | fn countdown_async(app_handle: tauri::AppHandle) { 20 | // 取消之前的倒计时 21 | if let Some(sender) = REMINDER_PAGE_COUNTDOWN_SENDER.lock().unwrap().take() { 22 | let _ = sender.try_send(()); 23 | } 24 | 25 | // 创建新的 channel 26 | let (tx, mut rx) = mpsc::channel(1); 27 | *REMINDER_PAGE_COUNTDOWN_SENDER.lock().unwrap() = Some(tx); 28 | 29 | // 只在需要移动所有权到异步闭包时才 clone 30 | let app_handle = app_handle.clone(); 31 | tauri::async_runtime::spawn(async move { 32 | let mut countdown = 30; 33 | let _ = app_handle.emit("countdown", countdown); 34 | 35 | loop { 36 | tokio::select! { 37 | _ = rx.recv() => { 38 | break; // 收到取消信号 39 | } 40 | _ = sleep(Duration::from_secs(1)) => { 41 | countdown -= 1; 42 | let _ = app_handle.emit("countdown", countdown); 43 | if countdown <= 0 { 44 | break; 45 | } 46 | } 47 | } 48 | } 49 | }); 50 | } 51 | 52 | #[cfg(any(target_os = "macos", target_os = "linux"))] 53 | #[tauri::command] 54 | pub fn call_reminder(app_handle: tauri::AppHandle) -> bool { 55 | println!("call_reminder"); 56 | 57 | pause_timer(); 58 | window::show_reminder_windows(&app_handle); 59 | 60 | countdown_async(app_handle); 61 | 62 | true 63 | } 64 | 65 | #[cfg(target_os = "windows")] 66 | // windows的command居然要加async,笑死,浪费我2个晚上的时间 67 | // https://github.com/tauri-apps/wry/issues/583 68 | #[tauri::command] 69 | pub async fn call_reminder(app_handle: tauri::AppHandle) -> bool { 70 | println!("call_reminder"); 71 | 72 | pause_timer(); 73 | // 直接传递引用,避免不必要的 clone 74 | window::show_reminder_windows(&app_handle); 75 | 76 | countdown_async(app_handle); 77 | 78 | true 79 | } 80 | 81 | #[tauri::command] 82 | pub fn hide_reminder_windows(app_handle: tauri::AppHandle) { 83 | window::hide_reminder_windows(&app_handle); 84 | 85 | // 取消之前的倒计时 86 | if let Some(sender) = REMINDER_PAGE_COUNTDOWN_SENDER.lock().unwrap().take() { 87 | let _ = sender.try_send(()); 88 | } 89 | } 90 | 91 | #[tauri::command] 92 | pub fn hide_reminder_window(app_handle: tauri::AppHandle, label: &str) { 93 | window::hide_reminder_window(&app_handle, &label); 94 | } 95 | 96 | #[tauri::command] 97 | pub fn reset_timer() { 98 | // 重置计时器 99 | IS_RUNNING.store(false, Ordering::SeqCst); 100 | 101 | tauri::async_runtime::spawn(async move { 102 | sleep(Duration::from_millis(1000)).await; 103 | IS_RUNNING.store(true, Ordering::SeqCst); 104 | }); 105 | } 106 | 107 | #[tauri::command] 108 | pub fn pause_timer() { 109 | IS_RUNNING.store(false, Ordering::SeqCst); 110 | } 111 | 112 | #[tauri::command] 113 | pub fn start_timer() { 114 | IS_RUNNING.store(true, Ordering::SeqCst); 115 | } 116 | 117 | #[tauri::command] 118 | pub async fn quit(app_handle: tauri::AppHandle) { 119 | app_handle.exit(0); 120 | } 121 | 122 | #[derive(Serialize)] 123 | pub struct SettingResponse { 124 | screen: i32, 125 | } 126 | 127 | #[tauri::command] 128 | pub fn setting(app_handle: tauri::AppHandle) -> SettingResponse { 129 | let main_window = app_handle.get_webview_window("main").unwrap(); 130 | let main_window_size = main_window.inner_size().unwrap(); 131 | println!("main_window_size: {:?}", main_window_size); 132 | 133 | SettingResponse { screen: 2 } 134 | } 135 | 136 | #[derive(Serialize, Debug)] 137 | pub struct AppRuntimeInfoResponse { 138 | is_running: bool, 139 | app_settings: AppSettings, 140 | version: String, 141 | os_version: String, 142 | os_arch: String, 143 | chip_info: String, 144 | } 145 | 146 | #[tauri::command(async)] 147 | pub async fn get_app_runtime_info( 148 | app_handle: tauri::AppHandle, 149 | ) -> Result { 150 | let app_settings = AppSettings::load_from_store::(&app_handle); 151 | let is_running = IS_RUNNING.load(Ordering::SeqCst); 152 | let version = app_handle.package_info().version.to_string(); 153 | 154 | // 获取操作系统信息 155 | let os_info = format!("{} {}", std::env::consts::OS, std::env::consts::ARCH); 156 | let os_arch = std::env::consts::ARCH.to_string(); 157 | 158 | // 获取芯片信息 159 | let chip_info = { 160 | #[cfg(target_os = "macos")] 161 | { 162 | let output = std::process::Command::new("sysctl") 163 | .args(["-n", "machdep.cpu.brand_string"]) 164 | .output() 165 | .map_err(|e| e.to_string())?; 166 | String::from_utf8_lossy(&output.stdout).trim().to_string() 167 | } 168 | #[cfg(not(target_os = "macos"))] 169 | { 170 | "Unknown".to_string() 171 | } 172 | }; 173 | 174 | Ok(AppRuntimeInfoResponse { 175 | app_settings, 176 | is_running, 177 | version, 178 | os_version: os_info, 179 | os_arch, 180 | chip_info, 181 | }) 182 | } 183 | 184 | #[tauri::command] 185 | pub async fn get_installed_apps() -> Vec { 186 | util::get_installed_apps().await 187 | } 188 | -------------------------------------------------------------------------------- /src-tauri/src/core/window/macos.rs: -------------------------------------------------------------------------------- 1 | use tauri::{LogicalPosition, LogicalSize, Manager}; 2 | use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior; 3 | use tauri_nspanel::{ManagerExt, WebviewWindowExt}; 4 | 5 | const NSWindowStyleMaskUtilityWindow: i32 = 1 << 7; 6 | 7 | // 窗口度量结构体 8 | #[derive(Debug, Clone)] 9 | struct WindowMetrics { 10 | scaled_width: f64, 11 | scaled_height: f64, 12 | scaled_position: LogicalPosition, 13 | } 14 | 15 | // 统一的窗口度量计算函数,提供精确的缩放和舍入 16 | fn calculate_window_metrics(monitor: &tauri::Monitor) -> WindowMetrics { 17 | let size = monitor.size(); 18 | let scale_factor = monitor.scale_factor(); 19 | let position = monitor.position(); 20 | 21 | // 添加舍入处理,确保像素对齐 22 | let scaled_width = (size.width as f64 / scale_factor).round(); 23 | let scaled_height = (size.height as f64 / scale_factor).round(); 24 | let scaled_position = LogicalPosition::new( 25 | (position.x as f64 / scale_factor).round(), 26 | (position.y as f64 / scale_factor).round(), 27 | ); 28 | 29 | WindowMetrics { 30 | scaled_width, 31 | scaled_height, 32 | scaled_position, 33 | } 34 | } 35 | 36 | pub fn show_reminder(app_handle: &tauri::AppHandle) { 37 | println!("[macos] show_reminder"); 38 | 39 | if let Ok(panel) = app_handle.get_webview_panel("reminder_0") { 40 | if let Ok(monitors) = app_handle.available_monitors() { 41 | for (index, monitor) in monitors.iter().enumerate() { 42 | let reminder_label = format!("reminder_{}", index); 43 | 44 | println!("[macos] show_reminder_windows: {}", reminder_label); 45 | 46 | // 检查是否已存在提醒窗口 47 | if let Ok(panel) = app_handle.get_webview_panel(&reminder_label) { 48 | let win = app_handle.get_webview_window(&reminder_label).unwrap(); 49 | 50 | // 使用统一的计算函数 51 | let metrics = calculate_window_metrics(monitor); 52 | 53 | println!( 54 | "更新窗口 {}: 新尺寸=({:.0}, {:.0}), 新位置={:?}", 55 | reminder_label, 56 | metrics.scaled_width, 57 | metrics.scaled_height, 58 | metrics.scaled_position 59 | ); 60 | 61 | // 同时更新尺寸和位置,确保占满全屏 62 | let _ = win.set_size(LogicalSize::new( 63 | metrics.scaled_width, 64 | metrics.scaled_height, 65 | )); 66 | let _ = win.set_position(metrics.scaled_position); 67 | panel.show(); 68 | } else { 69 | // 接入新的外接屏幕,需要重新创建Window 70 | show_or_create_reminder_window(&app_handle); 71 | } 72 | } 73 | } 74 | } else { 75 | show_or_create_reminder_window(&app_handle); 76 | } 77 | } 78 | 79 | fn show_or_create_reminder_window(app_handle: &tauri::AppHandle) { 80 | if let Ok(monitors) = app_handle.available_monitors() { 81 | for (index, monitor) in monitors.iter().enumerate() { 82 | let reminder_label = format!("reminder_{}", index); 83 | 84 | // 检查是否已存在提醒窗口 85 | if let Ok(panel) = app_handle.get_webview_panel(&reminder_label) { 86 | panel.show(); 87 | continue; 88 | } 89 | 90 | // 使用统一的计算函数 91 | let metrics = calculate_window_metrics(monitor); 92 | 93 | println!( 94 | "创建窗口 {}: size={:?}, position={:?}, scale_factor={:.2}, scaled_size=({:.0}, {:.0})", 95 | index, monitor.size(), monitor.position(), monitor.scale_factor(), 96 | metrics.scaled_width, metrics.scaled_height 97 | ); 98 | 99 | let window = tauri::WebviewWindowBuilder::new( 100 | app_handle, 101 | format!("reminder_{}", index), 102 | tauri::WebviewUrl::App("reminder/".into()), 103 | ) 104 | .decorations(false) 105 | .transparent(true) 106 | .always_on_top(true) 107 | .visible_on_all_workspaces(true) 108 | .inner_size(metrics.scaled_width, metrics.scaled_height) 109 | .position(metrics.scaled_position.x, metrics.scaled_position.y) 110 | .build() 111 | .expect(&format!("failed to create reminder window {}", index)); 112 | 113 | let panel = window.to_panel().unwrap(); 114 | panel.set_level(26); 115 | 116 | panel.set_style_mask(NSWindowStyleMaskUtilityWindow); 117 | 118 | // // 在各个桌面空间、全屏中共享窗口 119 | panel.set_collection_behaviour( 120 | NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces 121 | | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary 122 | | NSWindowCollectionBehavior::NSWindowCollectionBehaviorIgnoresCycle, 123 | ); 124 | } 125 | } 126 | } 127 | 128 | pub fn hide_reminder(app_handle: &tauri::AppHandle) { 129 | if let Ok(monitors) = app_handle.available_monitors() { 130 | for (index, monitor) in monitors.iter().enumerate() { 131 | let reminder_label = format!("reminder_{}", index); 132 | 133 | println!("hide_reminder_windows: {}", reminder_label); // 打印 reminder_label 的值,以检查是否正确获取了窗口标签 134 | 135 | // 检查是否已存在提醒窗口 136 | if let Ok(panel) = app_handle.get_webview_panel(&reminder_label) { 137 | panel.order_out(None); 138 | } 139 | } 140 | } 141 | } 142 | 143 | pub fn hide_reminder_single(app_handle: &tauri::AppHandle, label: &str) { 144 | if let Ok(panel) = app_handle.get_webview_panel(&label) { 145 | panel.order_out(None); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src-tauri/src/core/setup/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::pause_timer; 2 | // mod timer; 3 | use crate::timer::IS_RUNNING; 4 | use tauri::Manager; 5 | 6 | use std::thread; 7 | use std::time::Duration; 8 | 9 | use std::sync::atomic::Ordering; 10 | 11 | use crate::core::store::settings::AppSettings; 12 | use crate::core::util::is_frontapp_in_whitelist; 13 | use std::time::Instant; 14 | use tauri::Emitter; 15 | 16 | #[cfg(target_os = "macos")] 17 | mod macos; 18 | 19 | #[cfg(target_os = "macos")] 20 | pub use macos::*; 21 | 22 | #[cfg(target_os = "windows")] 23 | mod windows; 24 | #[cfg(target_os = "windows")] 25 | pub use windows::*; 26 | 27 | #[cfg(target_os = "linux")] 28 | mod linux; 29 | #[cfg(target_os = "linux")] 30 | pub use linux::*; 31 | 32 | struct TimerThreads { 33 | timer: thread::JoinHandle<()>, 34 | lock: thread::JoinHandle<()>, 35 | } 36 | 37 | pub fn default(app_handle: &tauri::AppHandle) { 38 | #[cfg(target_os = "macos")] 39 | { 40 | let _ = app_handle.set_activation_policy(tauri::ActivationPolicy::Accessory); 41 | } 42 | 43 | let is_running = IS_RUNNING.clone(); 44 | 45 | // 启动计时器线程 46 | let timer_handle = app_handle.clone(); 47 | let timer_thread = thread::Builder::new() 48 | .name("timer-thread".into()) 49 | .spawn(move || loop { 50 | if !is_running.load(Ordering::SeqCst) { 51 | thread::sleep(Duration::from_millis(100)); 52 | continue; 53 | } 54 | run_timer(&timer_handle, &is_running); 55 | }) 56 | .expect("无法创建计时器线程"); 57 | 58 | // 启动锁屏监听线程 59 | let lock_thread = thread::Builder::new() 60 | .name("lock-monitor-thread".into()) 61 | .spawn(monitor_lock_screen) 62 | .expect("无法创建锁屏监听线程"); 63 | 64 | // 保存线程句柄 65 | app_handle.manage(TimerThreads { 66 | timer: timer_thread, 67 | lock: lock_thread, 68 | }); 69 | 70 | // 设置窗口行为 71 | let main_window = app_handle.get_webview_window("main").unwrap(); 72 | let window_handle = main_window.clone(); 73 | main_window.on_window_event(move |event| { 74 | match event { 75 | tauri::WindowEvent::CloseRequested { api, .. } => { 76 | api.prevent_close(); 77 | let _ = window_handle.hide(); 78 | println!("窗口关闭请求被拦截"); 79 | } 80 | // #[cfg(target_os = "windows")] 81 | // tauri::WindowEvent::Destroyed => { 82 | // // Windows 下窗口最小化时隐藏窗口 83 | // let _ = window_handle.hide(); 84 | // println!("窗口最小化,已隐藏"); 85 | // } 86 | // #[cfg(target_os = "windows")] 87 | // tauri::WindowEvent::Focused(focused) => { 88 | // if !focused { 89 | // // Windows 下窗口失去焦点时保存当前状态 90 | // let _ = window_handle.hide(); 91 | // } 92 | // } 93 | _ => {} 94 | } 95 | }); 96 | } 97 | 98 | // 提取计时器逻辑 99 | fn run_timer(app_handle: &tauri::AppHandle, is_running: &std::sync::atomic::AtomicBool) { 100 | let mut tray = app_handle.tray_by_id("main-tray"); 101 | let mut timer = Instant::now(); 102 | let mut elapsed_total = 0; 103 | 104 | while is_running.load(Ordering::SeqCst) { 105 | let app_settings = AppSettings::load_from_store::(&app_handle); 106 | 107 | // 检查非工作状态 108 | if !app_settings.should_run_timer() { 109 | let (status, tooltip) = app_settings.get_status_message(); 110 | update_tray_status(&mut tray, status, tooltip); 111 | thread::sleep(Duration::from_secs(1)); 112 | continue; 113 | } 114 | 115 | let elapsed_secs = elapsed_total + timer.elapsed().as_secs(); 116 | 117 | // 处理白名单应用 118 | if is_frontapp_in_whitelist(&app_settings.whitelist_apps) { 119 | elapsed_total = elapsed_secs; 120 | update_tray_status(&mut tray, "暂停", "白名单应用前台运行中"); 121 | thread::sleep(Duration::from_secs(1)); 122 | timer = Instant::now(); 123 | continue; 124 | } 125 | 126 | let rest = app_settings.gap.saturating_sub(elapsed_secs); 127 | 128 | println!("rest {:?}", rest); 129 | 130 | // 更新托盘倒计时 131 | if app_settings.is_show_countdown { 132 | let countdown = format!("{}:{:02}", rest / 60, rest % 60); 133 | update_tray_status(&mut tray, &countdown, ""); 134 | } else { 135 | update_tray_status(&mut tray, "", ""); 136 | } 137 | 138 | if rest == 0 && app_settings.should_run_timer() { 139 | pause_timer(); 140 | if let Err(e) = app_handle.emit_to("main", "timer-complete", {}) { 141 | eprintln!("发送提醒事件失败: {}", e); 142 | } 143 | break; 144 | } 145 | 146 | thread::sleep(Duration::from_secs(1)); 147 | } 148 | } 149 | 150 | // 提取托盘状态更新逻辑 151 | fn update_tray_status(tray: &mut Option, status: &str, tooltip: &str) { 152 | if let Some(ref tray_handle) = tray { 153 | let formatted_status = if !status.is_empty() { 154 | // 使用等宽无衬线字体字符 155 | let status = status 156 | .replace("0", "𝟶") 157 | .replace("1", "𝟷") 158 | .replace("2", "𝟸") 159 | .replace("3", "𝟹") 160 | .replace("4", "𝟺") 161 | .replace("5", "𝟻") 162 | .replace("6", "𝟼") 163 | .replace("7", "𝟽") 164 | .replace("8", "𝟾") 165 | .replace("9", "𝟿") 166 | .replace(":", "∶"); 167 | format!("{}", status) 168 | } else { 169 | String::new() 170 | }; 171 | let _ = tray_handle.set_title(Some(&formatted_status)); 172 | let _ = tray_handle.set_tooltip(Some(tooltip)); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SelectPrimitive from "@radix-ui/react-select"; 5 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | function Select({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return ; 13 | } 14 | 15 | function SelectGroup({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return ; 19 | } 20 | 21 | function SelectValue({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ; 25 | } 26 | 27 | function SelectTrigger({ 28 | className, 29 | size = "default", 30 | children, 31 | ...props 32 | }: React.ComponentProps & { 33 | size?: "sm" | "default"; 34 | }) { 35 | return ( 36 | 45 | {children} 46 | 47 | 48 | 49 | 50 | ); 51 | } 52 | 53 | function SelectContent({ 54 | className, 55 | children, 56 | position = "popper", 57 | ...props 58 | }: React.ComponentProps) { 59 | return ( 60 | 61 | 72 | 73 | 80 | {children} 81 | 82 | 83 | 84 | 85 | ); 86 | } 87 | 88 | function SelectLabel({ 89 | className, 90 | ...props 91 | }: React.ComponentProps) { 92 | return ( 93 | 98 | ); 99 | } 100 | 101 | function SelectItem({ 102 | className, 103 | children, 104 | ...props 105 | }: React.ComponentProps) { 106 | return ( 107 | 115 | 116 | 117 | 118 | 119 | 120 | {children} 121 | 122 | ); 123 | } 124 | 125 | function SelectSeparator({ 126 | className, 127 | ...props 128 | }: React.ComponentProps) { 129 | return ( 130 | 135 | ); 136 | } 137 | 138 | function SelectScrollUpButton({ 139 | className, 140 | ...props 141 | }: React.ComponentProps) { 142 | return ( 143 | 151 | 152 | 153 | ); 154 | } 155 | 156 | function SelectScrollDownButton({ 157 | className, 158 | ...props 159 | }: React.ComponentProps) { 160 | return ( 161 | 169 | 170 | 171 | ); 172 | } 173 | 174 | export { 175 | Select, 176 | SelectContent, 177 | SelectGroup, 178 | SelectItem, 179 | SelectLabel, 180 | SelectScrollDownButton, 181 | SelectScrollUpButton, 182 | SelectSeparator, 183 | SelectTrigger, 184 | SelectValue, 185 | }; 186 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: "publish" 2 | 3 | on: 4 | push: 5 | # 匹配特定标签 (refs/tags) 6 | tags: 7 | - "v*" # 推送事件匹配 v*, 例如 v1.0,v20.15.10 等来触发工作流 8 | 9 | # This workflow will trigger on each push to the `release` branch to create or update a GitHub release, build your app, and upload the artifacts to the release. 10 | 11 | jobs: 12 | create-release: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - name: Set output 21 | id: vars 22 | run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT 23 | 24 | # 安装 Node.js 25 | - name: Setup node 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | 30 | # 发布 Release,使用自定义名称 31 | - name: Generate changelog 32 | id: create_release 33 | run: npx changelogithub --draft --name ${{ steps.vars.outputs.tag }} 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | release: 38 | needs: create-release 39 | # 由于需要创建release所以需要设置写入权限 40 | permissions: 41 | contents: write 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | include: 46 | - platform: "macos-latest" 47 | target: "aarch64-apple-darwin" 48 | - platform: "macos-latest" 49 | target: "x86_64-apple-darwin" 50 | 51 | - platform: "windows-latest" 52 | target: "x86_64-pc-windows-msvc" 53 | - platform: "windows-latest" 54 | target: "i686-pc-windows-msvc" 55 | - platform: "windows-latest" 56 | target: "aarch64-pc-windows-msvc" 57 | 58 | - platform: "ubuntu-22.04" 59 | target: "x86_64-unknown-linux-gnu" 60 | - platform: "ubuntu-22.04" 61 | target: "aarch64-unknown-linux-gnu" 62 | 63 | runs-on: ${{ matrix.platform }} 64 | steps: 65 | - name: Checkout repository 66 | uses: actions/checkout@v4 67 | 68 | - name: install Rust stable 69 | uses: dtolnay/rust-toolchain@stable 70 | with: 71 | # Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds. 72 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 73 | # 使用 Rust 缓存,加快安装速度 74 | - name: Rust cache 75 | uses: swatinem/rust-cache@v2 76 | with: 77 | workspaces: target 78 | 79 | - name: Install rust target 80 | run: rustup target add ${{ matrix.target }} 81 | 82 | - name: Install dependencies (ubuntu only) 83 | if: matrix.platform == 'ubuntu-22.04' 84 | run: | 85 | sudo apt-get update 86 | sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 87 | 88 | - name: Add ARM64 architecture (for cross-compilation) 89 | if: matrix.platform == 'ubuntu-22.04' && matrix.target == 'aarch64-unknown-linux-gnu' 90 | run: | 91 | sudo dpkg --add-architecture arm64 92 | # 备份原有sources.list并创建新的 93 | sudo cp /etc/apt/sources.list /etc/apt/sources.list.backup 94 | sudo tee /etc/apt/sources.list > /dev/null <> $GITHUB_ENV 121 | echo "PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig" >> $GITHUB_ENV 122 | echo "PKG_CONFIG_SYSROOT_DIR=/" >> $GITHUB_ENV 123 | echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> $GITHUB_ENV 124 | echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV 125 | 126 | - name: setup node 127 | uses: actions/setup-node@v4 128 | with: 129 | node-version: lts/* 130 | 131 | - name: Sync node version and setup cache 132 | uses: actions/setup-node@v4 133 | with: 134 | node-version: 20 135 | cache: yarn 136 | 137 | - name: install frontend dependencies 138 | run: yarn install # change this to npm, pnpm or bun depending on which one you use. 139 | # 使用tauri actions 140 | - name: Build the app 141 | id: build 142 | uses: tauri-apps/tauri-action@v0 143 | env: 144 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 145 | # 使用之前配置的私钥 146 | TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} 147 | # 使用之前配置的私钥密码 148 | TAURI_SIGNING_PRIVATE_KEY_PASSWORD: 149 | with: 150 | tagName: ${{ github.ref_name }} 151 | releaseName: "Shui v__VERSION__" 152 | releaseBody: "Feature" 153 | releaseDraft: true 154 | prerelease: false 155 | args: --target ${{ matrix.target }} ${{ matrix.target == 'aarch64-unknown-linux-gnu' && '--bundles deb,rpm' || '' }} 156 | 157 | upload-assets: 158 | needs: release 159 | runs-on: ubuntu-latest 160 | permissions: 161 | contents: write 162 | steps: 163 | - name: Checkout repository 164 | uses: actions/checkout@v4 165 | 166 | - name: setup node 167 | uses: actions/setup-node@v4 168 | with: 169 | node-version: 20 170 | 171 | - name: Upload assets 172 | # 只在正式版本标签时执行 (v1.0.0, v2.1.3 等),不在测试标签时执行 173 | if: ${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, 'test') && !contains(github.ref, 'beta') }} 174 | env: 175 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 176 | run: node .github/workflows/updater.mjs 177 | -------------------------------------------------------------------------------- /src-tauri/src/core/store/settings.rs: -------------------------------------------------------------------------------- 1 | use chrono::{Datelike, Local, NaiveTime}; 2 | use serde::Serialize; 3 | use tauri_plugin_store::{Store, StoreExt}; // 添加这行到文件顶部 4 | 5 | pub mod store_files { 6 | pub const CONFIG: &str = "config_store.json"; 7 | pub const DRINK_HISTORY: &str = "drink_history_store.json"; 8 | } 9 | 10 | pub mod config_store_category { 11 | pub const ALERT: &str = "alert"; 12 | pub const GENERAL: &str = "general"; 13 | } 14 | 15 | pub mod store_fields { 16 | pub const GAP: &str = "gap"; 17 | pub const GOLD: &str = "gold"; 18 | pub const WEEKDAYS: &str = "weekdays"; 19 | pub const WHITELIST_APPS: &str = "whitelist_apps"; 20 | pub const TIMESTART: &str = "timeStart"; 21 | pub const TIMEEND: &str = "timeEnd"; 22 | pub const ISCOUNTDOWN: &str = "isCountDown"; 23 | pub const ISFULLSCREEN: &str = "isFullScreen"; 24 | } 25 | 26 | #[derive(Debug, Clone, Serialize)] // 添加 Serialize 27 | pub struct AppSettingsMeta { 28 | pub weekdays: Vec, 29 | pub current_time: NaiveTime, 30 | pub time_start: NaiveTime, 31 | pub time_end: NaiveTime, 32 | pub today_weekday: u64, 33 | pub gap_minutes: u64, 34 | } 35 | 36 | #[derive(Debug, Clone, Serialize)] // 添加 Serialize 37 | pub struct AppSettings { 38 | pub gold: u64, 39 | pub gap: u64, 40 | pub whitelist_apps: Vec, 41 | pub today_drink_amount: u64, 42 | pub is_work_day: bool, 43 | pub is_in_time_range: bool, 44 | pub is_show_countdown: bool, 45 | pub meta: AppSettingsMeta, 46 | } 47 | 48 | pub fn get_today_string() -> String { 49 | let now = Local::now(); 50 | now.format("%Y%m%d").to_string() 51 | } 52 | 53 | impl AppSettings { 54 | pub fn is_first_open(store: &Store) -> bool { 55 | store.is_empty() 56 | } 57 | 58 | pub fn init_store( 59 | store: &Store, 60 | ) -> Result<(), Box> { 61 | use serde_json::json; 62 | 63 | // 设置默认配置 64 | store.set( 65 | config_store_category::ALERT.to_string(), 66 | json!({ 67 | "gap": "20", 68 | "gold": "1000", 69 | "weekdays": [1, 2, 3, 4, 5], 70 | "timeStart": "09:00", 71 | "timeEnd": "18:00", 72 | "whitelist_apps": ["腾讯会议", "Zoom", "Google Meet", "Microsoft Teams"] 73 | }), 74 | ); 75 | store.set( 76 | config_store_category::GENERAL.to_string(), 77 | json!({ 78 | "isAutoStart": false, 79 | "isCountDown": true, 80 | "isFullScreen": true 81 | }), 82 | ); 83 | // 保存到文件 84 | store.save()?; 85 | 86 | Ok(()) 87 | } 88 | 89 | pub fn load_from_store(app_handle: &tauri::AppHandle) -> AppSettings { 90 | let config_store = app_handle 91 | .store(store_files::CONFIG) 92 | .expect("无法获取 Store"); 93 | let drink_history_store = app_handle 94 | .store(store_files::DRINK_HISTORY) 95 | .expect("无法获取 Store"); 96 | 97 | // 检查是否首次打开 98 | if Self::is_first_open(&config_store) { 99 | if let Err(e) = Self::init_store(&config_store) { 100 | println!("初始化配置失败: {:?}", e); 101 | } 102 | } 103 | 104 | let alert_config = config_store 105 | .get(config_store_category::ALERT) 106 | .and_then(|v| v.as_object().cloned()); 107 | 108 | let alert_config = alert_config.unwrap_or_default(); 109 | 110 | let gap_minutes = alert_config 111 | .get(store_fields::GAP) 112 | .and_then(|v| v.as_str()) 113 | .and_then(|s| s.parse::().ok()) 114 | .unwrap_or(20); 115 | 116 | let gold = alert_config 117 | .get(store_fields::GOLD) 118 | .and_then(|v| v.as_str()) 119 | .and_then(|s| s.parse::().ok()) 120 | .unwrap_or(1000); 121 | 122 | let time_start = alert_config 123 | .get(store_fields::TIMESTART) 124 | .and_then(|v| v.as_str()) 125 | .and_then(|s| NaiveTime::parse_from_str(s, "%H:%M").ok()) 126 | .unwrap_or(NaiveTime::from_hms_opt(9, 0, 0).unwrap()); 127 | 128 | let time_end = alert_config 129 | .get(store_fields::TIMEEND) 130 | .and_then(|v| v.as_str()) 131 | .and_then(|s| NaiveTime::parse_from_str(s, "%H:%M").ok()) 132 | .unwrap_or(NaiveTime::from_hms_opt(18, 0, 0).unwrap()); 133 | 134 | let current_time = Local::now().time(); 135 | let is_in_time_range = if time_end == NaiveTime::from_hms_opt(0, 0, 0).unwrap() { 136 | // 如果结束时间是 00:00,表示次日 0 点 137 | current_time >= time_start || current_time <= time_end 138 | } else { 139 | // 普通情况 140 | current_time >= time_start && current_time <= time_end 141 | }; 142 | 143 | let weekdays = alert_config 144 | .get(store_fields::WEEKDAYS) 145 | .and_then(|v| v.as_array()) 146 | .map(|arr| arr.iter().filter_map(|v| v.as_u64()).collect::>()) 147 | .unwrap_or_else(|| vec![]); // 默认为空数组 148 | // 获取今天是星期几(0-6,0 表示星期天) 149 | let today_weekday = Local::now().weekday().num_days_from_sunday() as u64; 150 | let is_work_day = weekdays.contains(&today_weekday); 151 | 152 | let drink_amount = drink_history_store 153 | .get(&get_today_string()) 154 | .and_then(|v| v.as_u64()) 155 | .unwrap_or(0); 156 | 157 | let whitelist_apps = alert_config 158 | .get(store_fields::WHITELIST_APPS) 159 | .and_then(|v| v.as_array()) 160 | .map(|arr| { 161 | arr.iter() 162 | .filter_map(|v| v.as_str()) 163 | .map(|s| s.to_string()) 164 | .collect::>() 165 | }) 166 | .unwrap_or_else(|| vec![]); // 默认为空数组 167 | 168 | let is_show_countdown = config_store 169 | .get(config_store_category::GENERAL) 170 | .and_then(|v| v.as_object().cloned()) 171 | .and_then(|obj| obj.get(store_fields::ISCOUNTDOWN).cloned()) 172 | .and_then(|v| v.as_bool()) 173 | .unwrap_or(true); 174 | 175 | AppSettings { 176 | gold: gold, 177 | gap: gap_minutes * 60, 178 | whitelist_apps: whitelist_apps, 179 | today_drink_amount: drink_amount, 180 | is_work_day: is_work_day, 181 | is_in_time_range: is_in_time_range, 182 | is_show_countdown: is_show_countdown, 183 | meta: AppSettingsMeta { 184 | weekdays, 185 | current_time, 186 | time_start, 187 | time_end, 188 | today_weekday, 189 | gap_minutes, 190 | }, 191 | } 192 | } 193 | 194 | pub fn should_run_timer(&self) -> bool { 195 | self.is_work_day && self.is_in_time_range && self.today_drink_amount < self.gold 196 | } 197 | 198 | pub fn get_status_message(&self) -> (&str, &str) { 199 | if self.today_drink_amount >= self.gold { 200 | ("已达标", "太棒啦,再接再厉") 201 | } else { 202 | ("", "非工作日或非工作时间") 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/app/setting/about/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { openUrl } from "@tauri-apps/plugin-opener"; 3 | import { getVersion } from "@tauri-apps/api/app"; 4 | import { useState, useEffect } from "react"; 5 | import { Button } from "@/components/ui/button"; 6 | import { invoke } from "@tauri-apps/api/core"; 7 | import { writeText } from "@tauri-apps/plugin-clipboard-manager"; 8 | import { toast } from "sonner"; 9 | import { useUpdaterCheck, useUpdaterDownload } from "@/hooks/use-updater"; 10 | 11 | export default function About() { 12 | const [version, setVersion] = useState(""); 13 | const { checking, updateAvailable, updateInfo, checkForUpdate } = 14 | useUpdaterCheck(); 15 | const { installing, progress, downloadAndInstall } = useUpdaterDownload(); 16 | 17 | useEffect(() => { 18 | getVersion().then(setVersion); 19 | }, []); 20 | 21 | const openGithub = async () => { 22 | await openUrl("https://github.com/rock-zhang/Shui"); 23 | }; 24 | 25 | const handleCopyAppInfo = async () => { 26 | const appInfo = await invoke("get_app_runtime_info"); 27 | await writeText(JSON.stringify(appInfo)); 28 | toast.success("复制成功"); 29 | }; 30 | 31 | const handleCheck = async () => { 32 | await checkForUpdate(); 33 | 34 | if (updateAvailable && updateInfo) { 35 | toast.success(`发现新版本: ${updateInfo.version}`); 36 | } 37 | }; 38 | 39 | return ( 40 |
41 |

关于

42 | 43 |
44 |
45 |
46 | 52 | 58 | 59 | 62 |
63 |

64 | 这是一个帮助你养成健康饮水习惯的小工具。它会根据你设定的目标,在合适的时间提醒你喝水,帮助你保持充足的水分摄入,提升身体健康。 65 |

66 |

67 | 如果你有任何想法或遇到问题,欢迎通过以下方式与我们联系。你的反馈将帮助我们做得更好! 68 |

69 |
70 |
71 | 72 |
73 |
74 | 77 |

{version}

78 |
79 |
80 | 87 | {updateAvailable && ( 88 | 91 | )} 92 |
93 |
94 | 95 |
96 |
97 | 100 |

104 | 复制软件信息并提供给 Bug Issue 105 |

106 |
107 | 108 |
109 | 110 |
111 | 112 | 113 |
114 |
115 |
116 | 123 | 124 | 125 |
126 |
127 |

微信号:slash__z

128 |
129 |
130 |
131 |
132 | 139 | 140 | 141 |
142 |
143 |

144 | hey47_zhang@163.com 145 |

146 |
147 |
148 |
149 | 150 |
154 |
155 | 160 | 161 | 162 |
163 |
164 |

165 | https://github.com/rock-zhang/Shui 166 |

167 |
168 |
169 | 170 | {/*
171 |
172 |
173 | 微信群 178 |
179 |

加入微信群

180 |
181 |
*/} 182 |
183 |
184 | ); 185 | } 186 | -------------------------------------------------------------------------------- /src-tauri/src/core/setup/windows.rs: -------------------------------------------------------------------------------- 1 | use std::{io, ptr}; 2 | use winapi::{ 3 | ctypes::c_void, 4 | shared::{ 5 | guiddef::GUID, 6 | minwindef::{LPARAM, LRESULT, TRUE, UINT, WPARAM}, 7 | windef::HWND, 8 | }, 9 | um::{ 10 | libloaderapi::GetModuleHandleW, 11 | winuser::{ 12 | CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, PostQuitMessage, 13 | RegisterClassExW, RegisterPowerSettingNotification, TranslateMessage, CW_USEDEFAULT, 14 | MSG, PBT_POWERSETTINGCHANGE, POWERBROADCAST_SETTING, WM_DESTROY, WM_POWERBROADCAST, 15 | WM_WTSSESSION_CHANGE, WNDCLASSEXW, WS_EX_OVERLAPPEDWINDOW, 16 | }, 17 | wtsapi32::*, 18 | }, 19 | }; 20 | // use windows::Win32::Foundation::HWND; 21 | use chrono::{Datelike, Local, NaiveTime}; 22 | // use winapi::um::wtsapi32::{WTS_SESSION_LOCK, WTS_SESSION_UNLOCK}; 23 | use crate::timer::IS_RUNNING; 24 | use std::sync::atomic::Ordering; 25 | use windows::Win32::System::RemoteDesktop::WTSRegisterSessionNotification; 26 | use windows::Win32::System::RemoteDesktop::NOTIFY_FOR_THIS_SESSION; 27 | 28 | // 显示器状态 GUID 29 | const GUID_CONSOLE_DISPLAY_STATE: GUID = GUID { 30 | Data1: 0x6fe69556, 31 | Data2: 0x704a, 32 | Data3: 0x47a0, 33 | Data4: [0x8f, 0x24, 0xc2, 0x8d, 0x93, 0x6f, 0xda, 0x47], 34 | }; 35 | 36 | // 显示器状态枚举 37 | #[derive(Debug)] 38 | enum MonitorState { 39 | Off, 40 | On, 41 | Dim, 42 | Unknown(u32), 43 | } 44 | 45 | impl From for MonitorState { 46 | fn from(state: u32) -> Self { 47 | match state { 48 | 0 => MonitorState::Off, 49 | 1 => MonitorState::On, 50 | 2 => MonitorState::Dim, 51 | x => MonitorState::Unknown(x), 52 | } 53 | } 54 | } 55 | 56 | fn reset_timer_running(lock_state: bool) { 57 | IS_RUNNING.store(lock_state, Ordering::SeqCst); 58 | let (status, action) = if lock_state { 59 | ("锁屏", "停止") 60 | } else { 61 | ("解锁", "开始") 62 | }; 63 | println!("系统{},{}计时", status, action); 64 | } 65 | 66 | // 窗口处理函数 67 | extern "system" fn wnd_proc(hwnd: HWND, msg: UINT, wparam: WPARAM, lparam: LPARAM) -> LRESULT { 68 | println!("wparam: {:?}: {:?}", wparam, Local::now().time()); 69 | match msg { 70 | WM_WTSSESSION_CHANGE => { 71 | match wparam as u32 { 72 | 7 => { 73 | println!("[事件] 系统已锁定"); 74 | reset_timer_running(false); 75 | } 76 | 8 => { 77 | println!("[事件] 系统已解锁"); 78 | reset_timer_running(true); 79 | } 80 | _ => {} 81 | } 82 | 0 83 | } 84 | WM_POWERBROADCAST => { 85 | if wparam == PBT_POWERSETTINGCHANGE as WPARAM { 86 | println!( 87 | "PBT_POWERSETTINGCHANGE: {:?}: {:?}", 88 | wparam, 89 | Local::now().time() 90 | ); 91 | let setting = lparam as *const POWERBROADCAST_SETTING; 92 | println!("setting: {:?}: {:?}", setting, Local::now().time()); 93 | unsafe { 94 | if let Ok(state) = handle_power_setting(setting) { 95 | println!( 96 | "handle_power_setting: {:?}: {:?}", 97 | state, 98 | Local::now().time() 99 | ); 100 | match state { 101 | MonitorState::Off => { 102 | println!("[事件] 显示器已关闭"); 103 | reset_timer_running(false); 104 | } 105 | MonitorState::On => { 106 | println!("[事件] 显示器已打开"); 107 | reset_timer_running(true); 108 | } 109 | MonitorState::Dim => println!("[事件] 显示器变暗"), 110 | MonitorState::Unknown(x) => println!("[事件] 未知状态: {}", x), 111 | } 112 | } 113 | } 114 | } 115 | TRUE.try_into().unwrap() 116 | } 117 | WM_DESTROY => { 118 | unsafe { PostQuitMessage(0) }; 119 | 0 120 | } 121 | _ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }, 122 | } 123 | } 124 | 125 | // 处理电源设置变化 126 | unsafe fn handle_power_setting(setting: *const POWERBROADCAST_SETTING) -> io::Result { 127 | // 使用 memcmp 比较 GUID 128 | let setting_guid = &(*setting).PowerSetting as *const GUID; 129 | let console_guid = &GUID_CONSOLE_DISPLAY_STATE as *const GUID; 130 | let guid_size = std::mem::size_of::(); 131 | 132 | if std::ptr::eq(setting_guid, console_guid) 133 | || std::slice::from_raw_parts(setting_guid as *const u8, guid_size) 134 | == std::slice::from_raw_parts(console_guid as *const u8, guid_size) 135 | { 136 | let data = (*setting).Data.as_ptr() as *const u32; 137 | Ok(MonitorState::from(*data)) 138 | } else { 139 | Err(io::Error::new( 140 | io::ErrorKind::InvalidData, 141 | "非显示器状态事件", 142 | )) 143 | } 144 | } 145 | 146 | pub fn monitor_lock_screen() { 147 | let class_name = match widestring::U16CString::from_str("ScreenMonitorClass") { 148 | Ok(name) => name, 149 | Err(e) => { 150 | println!("创建窗口类名称失败: {}", e); 151 | return; 152 | } 153 | }; 154 | 155 | let wnd_class = WNDCLASSEXW { 156 | cbSize: std::mem::size_of::() as u32, 157 | style: 0, 158 | lpfnWndProc: Some(wnd_proc), 159 | cbClsExtra: 0, 160 | cbWndExtra: 0, 161 | hInstance: unsafe { GetModuleHandleW(ptr::null()) }, 162 | hIcon: ptr::null_mut(), 163 | hCursor: ptr::null_mut(), 164 | hbrBackground: ptr::null_mut(), 165 | lpszMenuName: ptr::null(), 166 | lpszClassName: class_name.as_ptr(), 167 | hIconSm: ptr::null_mut(), 168 | }; 169 | 170 | unsafe { 171 | if RegisterClassExW(&wnd_class) == 0 { 172 | println!("注册窗口类失败"); 173 | return; 174 | } 175 | 176 | let window_name = match widestring::U16CString::from_str("Screen Monitor") { 177 | Ok(name) => name, 178 | Err(e) => { 179 | println!("创建窗口名称失败: {}", e); 180 | return; 181 | } 182 | }; 183 | 184 | let hwnd = CreateWindowExW( 185 | WS_EX_OVERLAPPEDWINDOW, 186 | class_name.as_ptr(), 187 | window_name.as_ptr(), 188 | 0, 189 | CW_USEDEFAULT, 190 | CW_USEDEFAULT, 191 | CW_USEDEFAULT, 192 | CW_USEDEFAULT, 193 | ptr::null_mut(), 194 | ptr::null_mut(), 195 | wnd_class.hInstance, 196 | ptr::null_mut(), 197 | ); 198 | 199 | if hwnd.is_null() { 200 | println!("创建窗口失败"); 201 | return; 202 | } 203 | 204 | // 注册会话通知 205 | if unsafe { 206 | WTSRegisterSessionNotification( 207 | windows::Win32::Foundation::HWND(hwnd as isize), 208 | NOTIFY_FOR_THIS_SESSION, 209 | ) 210 | .as_bool() 211 | } { 212 | println!("监听已启动,尝试息屏/锁屏测试..."); 213 | } else { 214 | println!("注册会话通知失败"); 215 | return; 216 | } 217 | 218 | // 注册电源通知 219 | let _notification = 220 | RegisterPowerSettingNotification(hwnd as *mut c_void, &GUID_CONSOLE_DISPLAY_STATE, 0); 221 | 222 | println!("监听已启动,尝试息屏/锁屏测试..."); 223 | 224 | let mut msg = MSG::default(); 225 | while GetMessageW(&mut msg, ptr::null_mut(), 0, 0) > 0 { 226 | TranslateMessage(&msg); 227 | DispatchMessageW(&msg); 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src-tauri/src/core/window/linux/window_impl.rs: -------------------------------------------------------------------------------- 1 | use super::ext::LinuxWindowExt; 2 | use gtk::prelude::*; 3 | use tauri::{LogicalPosition, LogicalSize, Manager, WebviewUrl, WebviewWindowBuilder}; 4 | 5 | pub fn show_reminder(app_handle: &tauri::AppHandle) { 6 | println!("[linux] show_reminder"); 7 | 8 | // 检查合成器支持 9 | let has_compositing = LinuxWindowExt::has_compositing_support(); 10 | if !has_compositing { 11 | println!("警告: 系统可能不支持窗口透明度,提醒窗口可能显示不正确"); 12 | } 13 | 14 | if let Ok(monitors) = app_handle.available_monitors() { 15 | let needs_create = !monitors.iter().enumerate().any(|(index, _)| { 16 | let reminder_label = format!("reminder_{}", index); 17 | app_handle.get_webview_window(&reminder_label).is_some() 18 | }); 19 | 20 | if needs_create { 21 | show_or_create_reminder_window(app_handle); 22 | } else { 23 | update_existing_windows(app_handle, &monitors); 24 | } 25 | } 26 | } 27 | 28 | fn update_existing_windows(app_handle: &tauri::AppHandle, monitors: &Vec) { 29 | for (index, monitor) in monitors.iter().enumerate() { 30 | let reminder_label = format!("reminder_{}", index); 31 | 32 | if let Some(window) = app_handle.get_webview_window(&reminder_label) { 33 | // 重新计算窗口度量,确保占满全屏 34 | let (scaled_width, scaled_height, scaled_position) = calculate_window_metrics(monitor); 35 | 36 | // scaled_position 已经是缩放后的位置,直接转换为逻辑位置 37 | let logical_position = 38 | LogicalPosition::new(scaled_position.x as f64, scaled_position.y as f64); 39 | 40 | println!( 41 | "更新窗口 {}: 新尺寸=({:.0}, {:.0}), 新位置={:?}", 42 | reminder_label, scaled_width, scaled_height, logical_position 43 | ); 44 | 45 | // 同时更新尺寸和位置,确保占满全屏 46 | let _ = window.set_size(LogicalSize::new(scaled_width, scaled_height)); 47 | let _ = window.set_position(logical_position); 48 | let _ = window.show(); 49 | set_window_always_on_top(&window); 50 | 51 | // 尝试在所有工作区显示 52 | if let Ok(id) = window.title() { 53 | let _ = LinuxWindowExt::make_visible_on_all_workspaces(&id); 54 | } 55 | } 56 | } 57 | } 58 | 59 | fn show_or_create_reminder_window(app_handle: &tauri::AppHandle) { 60 | if let Ok(monitors) = app_handle.available_monitors() { 61 | // 检测桌面环境 62 | let desktop_env = LinuxWindowExt::detect_desktop_environment(); 63 | println!("当前桌面环境: {}", desktop_env); 64 | 65 | for (index, monitor) in monitors.iter().enumerate() { 66 | let reminder_label = format!("reminder_{}", index); 67 | 68 | if let Some(window) = app_handle.get_webview_window(&reminder_label) { 69 | let _ = window.show(); 70 | set_window_always_on_top(&window); 71 | continue; 72 | } 73 | 74 | let (scaled_width, scaled_height, position) = calculate_window_metrics(monitor); 75 | 76 | println!( 77 | "创建窗口 {}: size={:?}, position={:?}, scale_factor={:.2}, scaled_size=({:.0}, {:.0})", 78 | index, monitor.size(), monitor.position(), monitor.scale_factor(), 79 | scaled_width, scaled_height 80 | ); 81 | 82 | create_reminder_window( 83 | app_handle, 84 | &reminder_label, 85 | scaled_width, 86 | scaled_height, 87 | position, 88 | ); 89 | } 90 | } 91 | } 92 | 93 | fn calculate_window_metrics(monitor: &tauri::Monitor) -> (f64, f64, tauri::PhysicalPosition) { 94 | let size = monitor.size(); 95 | let scale_factor = monitor.scale_factor(); 96 | let position = monitor.position(); 97 | 98 | // 添加舍入处理,确保像素对齐和更好的显示效果 99 | let scaled_width = (size.width as f64 / scale_factor).round(); 100 | let scaled_height = (size.height as f64 / scale_factor).round(); 101 | let scaled_position = tauri::PhysicalPosition::new( 102 | ((position.x as f64 / scale_factor).round()) as i32, 103 | ((position.y as f64 / scale_factor).round()) as i32, 104 | ); 105 | 106 | (scaled_width, scaled_height, scaled_position) 107 | } 108 | 109 | fn create_reminder_window( 110 | app_handle: &tauri::AppHandle, 111 | label: &str, 112 | width: f64, 113 | height: f64, 114 | position: tauri::PhysicalPosition, 115 | ) { 116 | // 针对不同桌面环境调整窗口参数 117 | let desktop_env = LinuxWindowExt::detect_desktop_environment(); 118 | let (use_decorations, use_transparency) = match desktop_env.as_str() { 119 | // GNOME和KDE通常有良好的透明度支持 120 | env if env.contains("GNOME") || env.contains("KDE") => (false, true), 121 | // 其他环境可能需要调整 122 | _ => { 123 | if LinuxWindowExt::has_compositing_support() { 124 | (false, true) 125 | } else { 126 | (false, false) 127 | } 128 | } 129 | }; 130 | 131 | let mut builder = 132 | WebviewWindowBuilder::new(app_handle, label, WebviewUrl::App("reminder/".into())) 133 | .decorations(use_decorations) 134 | .transparent(use_transparency) 135 | .always_on_top(true) // 确保窗口总是在顶层 136 | .skip_taskbar(true) // 在任务栏中隐藏 137 | .inner_size(width, height) 138 | .position(position.x as f64, position.y as f64); 139 | 140 | let window = builder 141 | .build() 142 | .expect(&format!("failed to create reminder window {}", label)); 143 | 144 | // 设置窗口属性以覆盖dock 145 | if let Ok(gtk_window) = window.gtk_window() { 146 | println!("设置窗口属性"); 147 | 148 | // 设置为特殊的窗口类型 149 | gtk_window.set_type_hint(gtk::gdk::WindowTypeHint::Splashscreen); 150 | 151 | // 设置窗口为全屏 152 | gtk_window.fullscreen(); 153 | 154 | // 确保窗口总是在顶层 155 | gtk_window.set_keep_above(true); 156 | 157 | // 禁用窗口装饰 158 | gtk_window.set_decorated(false); 159 | 160 | // 设置窗口为全局显示 161 | gtk_window.stick(); 162 | 163 | // 禁用焦点 164 | gtk_window.set_accept_focus(false); 165 | gtk_window.set_can_focus(false); 166 | 167 | // 设置为跳过窗口管理器 168 | gtk_window.set_skip_taskbar_hint(true); 169 | gtk_window.set_skip_pager_hint(true); 170 | 171 | // 增强半透明效果 - 仅在支持合成的情况下 172 | if LinuxWindowExt::has_compositing_support() { 173 | // 设置窗口透明度 174 | gtk_window.set_opacity(0.95); // 95% 不透明度,类似 macOS 效果 175 | 176 | // 启用 RGBA 视觉效果 177 | let widget = gtk_window.upcast_ref::(); 178 | if let Some(screen) = widget.screen() { 179 | if let Some(rgba_visual) = screen.rgba_visual() { 180 | gtk_window.set_visual(Some(&rgba_visual)); 181 | } 182 | } 183 | 184 | // 设置 app_paintable 允许自定义绘制 185 | gtk_window.set_app_paintable(true); 186 | } 187 | 188 | println!("窗口属性设置完成"); 189 | } 190 | 191 | set_window_always_on_top(&window); 192 | set_visible_on_all_workspaces(&window); 193 | 194 | // 尝试在所有工作区显示 195 | if let Ok(id) = window.title() { 196 | let _ = LinuxWindowExt::make_visible_on_all_workspaces(&id); 197 | } 198 | } 199 | 200 | fn set_window_always_on_top(window: &tauri::WebviewWindow) { 201 | if let Ok(gtk_window) = window.gtk_window() { 202 | // 确保窗口总是在顶层 203 | gtk_window.set_keep_above(true); 204 | } 205 | } 206 | 207 | fn set_visible_on_all_workspaces(window: &tauri::WebviewWindow) { 208 | if let Ok(gtk_window) = window.gtk_window() { 209 | // 使窗口在所有工作区可见 210 | gtk_window.stick(); 211 | 212 | // 设置窗口类型为工具提示,这样可以在所有工作区上显示 213 | gtk_window.set_type_hint(gtk::gdk::WindowTypeHint::Utility); 214 | 215 | // 设置为非模态,允许其他窗口获得焦点 216 | gtk_window.set_modal(false); 217 | 218 | // 确保透明效果在所有工作区都有效 219 | if LinuxWindowExt::has_compositing_support() { 220 | gtk_window.set_opacity(0.95); 221 | 222 | let widget = gtk_window.upcast_ref::(); 223 | if let Some(screen) = widget.screen() { 224 | if let Some(rgba_visual) = screen.rgba_visual() { 225 | gtk_window.set_visual(Some(&rgba_visual)); 226 | } 227 | } 228 | 229 | gtk_window.set_app_paintable(true); 230 | } 231 | } 232 | } 233 | 234 | pub fn hide_reminder(app_handle: &tauri::AppHandle) { 235 | if let Ok(monitors) = app_handle.available_monitors() { 236 | for (index, _) in monitors.iter().enumerate() { 237 | let reminder_label = format!("reminder_{}", index); 238 | if let Some(window) = app_handle.get_webview_window(&reminder_label) { 239 | let _ = window.hide(); 240 | } 241 | } 242 | } 243 | } 244 | 245 | pub fn hide_reminder_single(app_handle: &tauri::AppHandle, label: &str) { 246 | if let Some(window) = app_handle.get_webview_window(label) { 247 | let _ = window.hide(); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/app/reminder/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { invoke } from "@tauri-apps/api/core"; 3 | import { 4 | isRegistered, 5 | register, 6 | unregisterAll, 7 | } from "@tauri-apps/plugin-global-shortcut"; 8 | import { useEffect, useState } from "react"; 9 | import { listen, TauriEvent } from "@tauri-apps/api/event"; 10 | import { Progress } from "@/components/ui/progress"; 11 | import { ArrowRight } from "lucide-react"; 12 | import { load } from "@tauri-apps/plugin-store"; 13 | import { 14 | isPermissionGranted, 15 | requestPermission, 16 | sendNotification, 17 | } from "@tauri-apps/plugin-notification"; 18 | import "./index.css"; 19 | import { currentMonitor, getCurrentWindow } from "@tauri-apps/api/window"; 20 | import { STORE_NAME } from "@/lib/constants"; 21 | import { usePlatform } from "@/hooks/use-platform"; 22 | 23 | function hideWindowAction() { 24 | invoke("hide_reminder_windows"); 25 | invoke("reset_timer"); 26 | unregisterAll(); 27 | } 28 | 29 | async function registerEscShortcut() { 30 | if (await isRegistered("Esc")) return; 31 | register("Esc", async () => { 32 | hideWindowAction(); 33 | }); 34 | } 35 | 36 | // 添加音效播放函数 37 | const playSound = () => { 38 | const audio = new Audio("/sounds/water-drop.mp3"); 39 | audio.volume = 0.5; // 设置音量为 50% 40 | audio.play().catch((err) => console.log("音频播放失败:", err)); 41 | }; 42 | 43 | const sendNativeNotification = async () => { 44 | let permissionGranted = await isPermissionGranted(); 45 | 46 | if (!permissionGranted) { 47 | const permission = await requestPermission(); 48 | permissionGranted = permission === "granted"; 49 | } 50 | 51 | // Once permission has been granted we can send the notification 52 | if (permissionGranted) { 53 | playSound(); // 添加音效 54 | 55 | sendNotification({ 56 | title: "🎉 太棒了!完成今日喝水目标", 57 | body: "再接再厉,继续保持健康好习惯!", 58 | }); 59 | } 60 | }; 61 | 62 | function getTodayDate() { 63 | const today = new Date(); 64 | return `${today.getFullYear()}${String(today.getMonth() + 1).padStart( 65 | 2, 66 | "0" 67 | )}${String(today.getDate()).padStart(2, "0")}`; 68 | } 69 | 70 | const waterOptions = [{ ml: 50 }, { ml: 100 }, { ml: 200 }, { ml: 300 }]; 71 | 72 | const reminderTexts = [ 73 | "每天建议饮水1500~1700ml,约7~8杯,保持健康水分 💧", 74 | "建议少量多次饮水,每次不超过200ml,呵护心肾健康 ❤️", 75 | "观察尿液颜色:淡黄色最健康,深黄需补水,无色可能过量 🌟", 76 | "晨起来杯温水(200~300ml),补充夜间水分,促进代谢 🌅", 77 | "餐前1小时喝水(100~150ml),帮助消化,事半功倍 🍽️", 78 | "睡前1小时少量饮水(约100ml),但别太多影响睡眠 😴", 79 | "运动后15分钟内补充200~300ml,平衡身体电解质 💪", 80 | "久坐办公记得每小时喝水100~150ml,保持清醒专注 💻", 81 | "喝35~40℃的水最好,太烫可能伤害身体,要适温 🌡️", 82 | "白开水和矿泉水是最佳选择,安全又健康 ✨", 83 | "不要用饮料代替水,果汁奶茶糖分高,咖啡浓茶会利尿 🥤", 84 | "饭中少喝水,可能影响消化,建议餐后半小时再补水 ⏰", 85 | "不要等到口渴才喝水,那时已经轻度脱水啦 💦", 86 | "水肿不是因为喝太多水,反而可能是喝得太少 💭", 87 | "高温天气补充淡盐水,平衡身体流失的钠钾 🌞", 88 | "乘坐飞机要多喝水,机舱很干燥,每小时喝100~150ml ✈️", 89 | ]; 90 | 91 | export default function ReminderPage() { 92 | const [reminderText, setReminderText] = useState(""); 93 | const [water, setWater] = useState({ 94 | gold: 0, 95 | drink: 0, 96 | }); 97 | const [countdown, setCountdown] = useState(30); 98 | const [monitorName, setMonitorName] = useState(""); 99 | const { isLinux } = usePlatform(); 100 | // 按天存储饮水量 101 | const todayDate = getTodayDate(); 102 | 103 | // 根据饮水量随机选择提醒文案 104 | useEffect(() => { 105 | setTimeout( 106 | () => { 107 | setReminderText( 108 | reminderTexts[Math.floor(Math.random() * reminderTexts.length)] 109 | ); 110 | }, 111 | reminderText ? 1000 : 0 112 | ); 113 | }, [water.drink]); 114 | 115 | useEffect(() => { 116 | registerEscShortcut(); 117 | 118 | listen("countdown", (event) => { 119 | setCountdown(event.payload as number); 120 | if (event.payload === 0) { 121 | setTimeout(hideWindowAction, 500); 122 | } 123 | }); 124 | 125 | // TODO:被其他窗口隐藏时,注销快捷键 126 | // 待确认多屏场景下,是否需要注销快捷键 127 | listen("reminder_already_hidden", () => { 128 | unregisterAll(); 129 | }); 130 | 131 | // 监听窗口显示事件 132 | listen(TauriEvent.WINDOW_FOCUS, () => { 133 | console.log("TauriEvent.WINDOW_FOCUS"); 134 | registerEscShortcut(); 135 | }); 136 | 137 | currentMonitor().then((mo) => { 138 | setMonitorName(mo?.name || ""); 139 | }); 140 | 141 | return () => { 142 | unregisterAll(); 143 | }; 144 | }, []); 145 | 146 | useEffect(() => { 147 | // 添加键盘事件监听作为 Linux 下的备选方案 148 | const handleKeyDown = (event: KeyboardEvent) => { 149 | if (event.key === "Escape") { 150 | console.log("Esc key detected via keyboard event"); 151 | hideWindowAction(); 152 | } 153 | }; 154 | 155 | // 在 Linux 系统上添加键盘事件监听 156 | if (isLinux) { 157 | document.addEventListener("keydown", handleKeyDown); 158 | } 159 | 160 | return () => { 161 | if (isLinux) { 162 | document.removeEventListener("keydown", handleKeyDown); 163 | } 164 | }; 165 | }, [isLinux]); 166 | 167 | useEffect(() => { 168 | if (!monitorName) return; 169 | listen(TauriEvent.WINDOW_MOVED, async () => { 170 | console.log("TauriEvent.WINDOW_MOVED", monitorName); 171 | const mo = await currentMonitor(); 172 | if (mo?.name !== monitorName) { 173 | // 外接屏幕变化时,隐藏窗口 174 | const win = await getCurrentWindow(); 175 | invoke("hide_reminder_window", { label: win.label }); 176 | } 177 | }); 178 | }, [monitorName]); 179 | 180 | useEffect(() => { 181 | const storeUpdate = async () => { 182 | const config_store = await load(STORE_NAME.config, { autoSave: false }); 183 | const drinkHistory = await load(STORE_NAME.drink_history, { 184 | autoSave: false, 185 | }); 186 | const [goldSetting, drink = 0] = await Promise.all([ 187 | config_store.get<{ 188 | gold: number; 189 | }>("alert"), 190 | drinkHistory.get(todayDate), 191 | ]); 192 | 193 | setWater({ 194 | gold: Number(goldSetting?.gold), 195 | drink, 196 | }); 197 | }; 198 | 199 | storeUpdate(); 200 | }, [countdown]); 201 | 202 | const [isClosing, setIsClosing] = useState(false); 203 | 204 | const handleWaterSelection = async (ml: number) => { 205 | const totalDrink = water.drink + ml; 206 | setWater({ 207 | ...water, 208 | drink: totalDrink, 209 | }); 210 | const store = await load(STORE_NAME.drink_history, { autoSave: false }); 211 | await store.set(todayDate, totalDrink); 212 | await store.save(); 213 | 214 | if (totalDrink >= water.gold) { 215 | sendNativeNotification(); 216 | } 217 | 218 | // 添加关闭动画 219 | setIsClosing(true); 220 | setTimeout( 221 | () => { 222 | hideWindowAction(); 223 | setIsClosing(false); 224 | }, 225 | isLinux ? 100 : 300 226 | ); // Linux 系统下无透明度,设置延时为 0,其他系统为 300ms 227 | }; 228 | 229 | const progress = (water.drink / water.gold) * 100; 230 | 231 | return ( 232 |
{ 234 | if (process.env.NODE_ENV === "production") e.preventDefault(); 235 | }} 236 | className={`reminder-page min-h-screen flex items-center justify-center relative transition-opacity duration-300 ${ 237 | isClosing ? "opacity-0" : "opacity-100" 238 | }`} 239 | > 240 |
241 | {countdown}s 后自动关闭 242 |
243 |
248 |

249 | 喝了么 250 |

251 |

{reminderText}

252 | 253 |
254 |
255 | 今日已喝: {water.drink}ml 256 | 目标: {water.gold}ml 257 |
258 | 259 |
260 | 261 |
262 | {waterOptions.map((option) => ( 263 | 274 | ))} 275 |
276 | 277 |
278 | 286 |
287 |
288 |
289 | ); 290 | } 291 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/app/setting/reminder/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Select, 5 | SelectContent, 6 | SelectGroup, 7 | SelectItem, 8 | SelectLabel, 9 | SelectTrigger, 10 | SelectValue, 11 | } from "@/components/ui/select"; 12 | import { useEffect, useState } from "react"; 13 | import { load } from "@tauri-apps/plugin-store"; 14 | import { useTray } from "@/hooks/use-tray"; 15 | import { invoke } from "@tauri-apps/api/core"; 16 | import { STORE_NAME } from "@/lib/constants"; 17 | import { 18 | Command, 19 | CommandEmpty, 20 | CommandGroup, 21 | CommandInput, 22 | CommandItem, 23 | } from "@/components/ui/command"; 24 | import { 25 | Popover, 26 | PopoverContent, 27 | PopoverTrigger, 28 | } from "@/components/ui/popover"; 29 | import { Badge } from "@/components/ui/badge"; 30 | import { Check, ChevronsUpDown } from "lucide-react"; 31 | import { cn } from "@/lib/utils"; 32 | import { Button } from "@/components/ui/button"; 33 | import { usePlatform } from "@/hooks/use-platform"; 34 | 35 | const goldList = ["1000", "1500", "2000", "2500", "3000", "3500", "4000"]; 36 | const gapList = ["10", "20", "30", "45", "60"]; 37 | 38 | if (process.env.NODE_ENV === "development") { 39 | gapList.unshift("1"); 40 | } 41 | 42 | export default function Home() { 43 | const [config, setConfig] = useState({ 44 | gold: goldList[0], 45 | gap: gapList[0], 46 | weekdays: [] as number[], 47 | timeStart: "09:00", 48 | timeEnd: "18:00", 49 | whitelist_apps: [] as string[], 50 | }); 51 | const [installedApps, setInstalledApps] = useState([]); 52 | const { isMacOS } = usePlatform(); 53 | 54 | useEffect(() => { 55 | // 加载已安装应用列表 56 | if (isMacOS) { 57 | invoke("get_installed_apps").then(setInstalledApps); 58 | } 59 | }, [isMacOS]); 60 | 61 | useTray(); 62 | 63 | useEffect(() => { 64 | async function loadConfig() { 65 | const store = await load(STORE_NAME.config, { autoSave: false }); 66 | const val = await store.get<{ 67 | gold: string; 68 | gap: string; 69 | weekdays: number[]; 70 | timeStart: string; 71 | timeEnd: string; 72 | }>("alert"); 73 | setConfig({ 74 | ...config, 75 | ...val, 76 | }); 77 | } 78 | 79 | loadConfig(); 80 | }, []); 81 | 82 | const saveConfig = async ( 83 | filed: string, 84 | value: string | number[] | string[] 85 | ) => { 86 | setConfig({ 87 | ...config, 88 | [filed]: value, 89 | }); 90 | 91 | const store = await load(STORE_NAME.config, { autoSave: false }); 92 | await store.set("alert", { 93 | ...config, 94 | [filed]: value, 95 | }); 96 | await store.save(); 97 | 98 | // 重置计时器 99 | invoke("reset_timer"); 100 | }; 101 | 102 | const selectedApp = config.whitelist_apps.filter((app) => 103 | installedApps.includes(app) 104 | ); 105 | 106 | return ( 107 |
108 |

提醒

109 | 110 |
111 |
112 | 115 |

119 | 完成目标前将定时提醒 120 |

121 |
122 | 143 |
144 |
145 |
146 | 149 |

153 |
154 | 175 |
176 | 177 |
178 |
179 | 182 |

按星期提醒

183 |
184 |
185 | {["日", "一", "二", "三", "四", "五", "六"].map((day, index) => ( 186 | 205 | ))} 206 |
207 |
208 | 209 |
210 |
211 | 214 |

215 | 仅在指定时间段内提醒 216 |

217 |
218 |
219 | 244 | 245 | 268 |
269 |
270 | 271 | {isMacOS && ( 272 |
273 |
274 | 277 |

278 | 在这些应用活跃时暂停提醒 279 |

280 |
281 | 282 | 283 | 304 | 305 | 309 | 310 | 311 | 未找到应用 312 | 313 | {installedApps.map((app) => ( 314 | { 317 | const newApps = selectedApp.includes(app) 318 | ? selectedApp.filter((a) => a !== app) 319 | : [...selectedApp, app]; 320 | saveConfig("whitelist_apps", newApps); 321 | }} 322 | > 323 | 331 | {app} 332 | 333 | ))} 334 | 335 | 336 | 337 | 338 |
339 | )} 340 | 341 | {/*
342 | 349 |
*/} 350 |
351 | ); 352 | } 353 | 354 | // 在文件顶部添加时间列表 355 | // 修改时间列表生成逻辑 356 | const timeList = [ 357 | ...Array.from({ length: 24 }, (_, i) => { 358 | const hour = i.toString().padStart(2, "0"); 359 | return [`${hour}:00`, `${hour}:30`]; 360 | }).flat(), 361 | "00:00", // 添加 0 点选项 362 | ]; 363 | --------------------------------------------------------------------------------