87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]",
92 | className
93 | )}
94 | {...props}
95 | />
96 | ))
97 | TableCell.displayName = "TableCell"
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | TableCaption.displayName = "TableCaption"
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | }
121 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as TabsPrimitive from "@radix-ui/react-tabs"
2 | import * as React from "react"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Tabs = TabsPrimitive.Root
7 |
8 | const TabsList = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | TabsList.displayName = TabsPrimitive.List.displayName
22 |
23 | const TabsTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
35 | ))
36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
37 |
38 | const TabsContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 | ))
51 | TabsContent.displayName = TabsPrimitive.Content.displayName
52 |
53 | export { Tabs, TabsContent, TabsList, TabsTrigger }
54 |
55 |
--------------------------------------------------------------------------------
/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ToastPrimitives from "@radix-ui/react-toast"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 | import { cn } from "@/lib/utils"
5 | import { Cross2Icon } from "@radix-ui/react-icons"
6 |
7 | const ToastProvider = ToastPrimitives.Provider
8 |
9 | const ToastViewport = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 | ))
22 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
23 |
24 | const toastVariants = cva(
25 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
26 | {
27 | variants: {
28 | variant: {
29 | default: "border bg-background text-foreground",
30 | destructive:
31 | "destructive group border-destructive bg-destructive text-destructive-foreground",
32 | },
33 | },
34 | defaultVariants: {
35 | variant: "default",
36 | },
37 | }
38 | )
39 |
40 | const Toast = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef &
43 | VariantProps
44 | >(({ className, variant, ...props }, ref) => {
45 | return (
46 |
51 | )
52 | })
53 | Toast.displayName = ToastPrimitives.Root.displayName
54 |
55 | const ToastAction = React.forwardRef<
56 | React.ElementRef,
57 | React.ComponentPropsWithoutRef
58 | >(({ className, ...props }, ref) => (
59 |
67 | ))
68 | ToastAction.displayName = ToastPrimitives.Action.displayName
69 |
70 | const ToastClose = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, ...props }, ref) => (
74 |
83 |
84 |
85 | ))
86 | ToastClose.displayName = ToastPrimitives.Close.displayName
87 |
88 | const ToastTitle = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ))
98 | ToastTitle.displayName = ToastPrimitives.Title.displayName
99 |
100 | const ToastDescription = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, ...props }, ref) => (
104 |
109 | ))
110 | ToastDescription.displayName = ToastPrimitives.Description.displayName
111 |
112 | type ToastProps = React.ComponentPropsWithoutRef
113 |
114 | type ToastActionElement = React.ReactElement
115 |
116 | export {
117 | type ToastProps,
118 | type ToastActionElement,
119 | ToastProvider,
120 | ToastViewport,
121 | Toast,
122 | ToastTitle,
123 | ToastDescription,
124 | ToastClose,
125 | ToastAction,
126 | }
127 |
--------------------------------------------------------------------------------
/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 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
19 |
28 |
29 | ))
30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
31 |
32 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
33 |
--------------------------------------------------------------------------------
/src/helpers/certificate-manager/index.ts:
--------------------------------------------------------------------------------
1 | import { appDataDir, resolveResource } from "@tauri-apps/api/path";
2 | import {
3 | BaseDirectory,
4 | exists,
5 | mkdir,
6 | readTextFile,
7 | remove,
8 | writeTextFile,
9 | } from "@tauri-apps/plugin-fs";
10 | import * as selfsigned from "selfsigned";
11 |
12 | let instance: CertificateManager | null = null;
13 |
14 | export class CertificateManager {
15 | private appDataDir: string = "";
16 |
17 | async deleteCertificateFiles(hostname: string) {
18 | await remove(`cert/${hostname}`, {
19 | baseDir: BaseDirectory.AppData,
20 | recursive: true,
21 | });
22 | }
23 |
24 | getManualCommandToDeleteCertificate(hostname: string) {
25 | return `rm -rf "${this.appDataDir}/cert/${hostname}"`;
26 | }
27 |
28 | async deleteAllNginxConfigurationFiles() {
29 | await remove(`conf/conf.d`, {
30 | baseDir: BaseDirectory.AppData,
31 | recursive: true,
32 | });
33 | }
34 |
35 | async deleteNginxConfigurationFiles(hostname: string) {
36 | if (
37 | !(await exists(`conf/conf.d/${hostname}.conf`, {
38 | baseDir: BaseDirectory.AppData,
39 | }))
40 | ) {
41 | return;
42 | }
43 | await remove(`conf/conf.d/${hostname}.conf`, {
44 | baseDir: BaseDirectory.AppData,
45 | });
46 | }
47 |
48 | async checkCertificateExists(hostname: string) {
49 | return await exists(`cert/${hostname}/cert.pem`, {
50 | baseDir: BaseDirectory.AppData,
51 | });
52 | }
53 |
54 | async cleanUp() {
55 | if (await exists(`conf/conf.d`, {
56 | baseDir: BaseDirectory.AppData,
57 | })) {
58 | await remove(`conf/conf.d`, {
59 | baseDir: BaseDirectory.AppData,
60 | recursive: true,
61 | });
62 | await mkdir(`conf/conf.d`, {
63 | baseDir: BaseDirectory.AppData,
64 | recursive: true,
65 | });
66 | }
67 | }
68 |
69 | async generateNginxConfigurationFiles(hostname: string, port: number) {
70 | // save to file
71 | if (
72 | !(await exists(`conf/conf.d`, {
73 | baseDir: BaseDirectory.AppData,
74 | }))
75 | ) {
76 | await mkdir(`conf/conf.d`, {
77 | baseDir: BaseDirectory.AppData,
78 | recursive: true,
79 | });
80 | }
81 |
82 | // read nginx conf file from bundle
83 | const nginxDefaultConfigPath = await resolveResource(
84 | "bundle/templates/default.nginx.conf.template"
85 | );
86 | const nginxDefaultConfigTemplate = await readTextFile(
87 | nginxDefaultConfigPath
88 | );
89 |
90 | await writeTextFile(`conf/nginx.conf`, nginxDefaultConfigTemplate, {
91 | baseDir: BaseDirectory.AppData,
92 | });
93 |
94 | // read nginx file from bundle
95 | const nginxConfigPath = await resolveResource(
96 | "bundle/templates/server.conf.template"
97 | );
98 | const nginxConfigTemplate = await readTextFile(nginxConfigPath);
99 |
100 | // replace all occurences of {DOMAIN_NAME} with hostname
101 | const upstreamSuffixUpdated = nginxConfigTemplate.replace(
102 | /{UPSTREAM_SUFFIX}/g,
103 | hostname.replace(/\./g, "_")
104 | );
105 | // replace all occurences of {DOMAIN_NAME} with hostname
106 | const nginxConfig = upstreamSuffixUpdated.replace(
107 | /{DOMAIN_NAME}/g,
108 | hostname
109 | );
110 | // replace all occurences of {PORT} with port
111 | const nginxConfigWithPort = nginxConfig.replace(/{PORT}/g, port.toString());
112 |
113 | // write nginx config to file
114 | await writeTextFile(`conf/conf.d/${hostname}.conf`, nginxConfigWithPort, {
115 | baseDir: BaseDirectory.AppData,
116 | });
117 | }
118 | constructor() {
119 | // init
120 | this.init();
121 | }
122 |
123 | async init() {
124 | this.appDataDir = await appDataDir();
125 | }
126 |
127 | public static shared(): CertificateManager {
128 | if (instance === null) {
129 | instance = new CertificateManager();
130 | }
131 | return instance;
132 | }
133 |
134 | public async generateCertificate(hostname: string) {
135 | var attrs = [{ name: "commonName", value: hostname }];
136 | interface IPems {
137 | private: string;
138 | public: string;
139 | cert: string;
140 | }
141 | const pems: IPems = await new Promise((resolve) => {
142 | var pems = selfsigned.generate(attrs, {
143 | days: 3650,
144 | algorithm: "sha256",
145 | keySize: 1024,
146 | extensions: [
147 | {
148 | name: "subjectAltName",
149 | altNames: [
150 | { type: 2, value: hostname },
151 | { type: 7, ip: "127.0.0.1" },
152 | ],
153 | },
154 | ],
155 | pkcs7: true, // include PKCS#7 as part of the output (default: false) });
156 | });
157 | resolve(pems);
158 | });
159 |
160 | // save to file
161 | if (
162 | !(await exists(`cert/${hostname}`, {
163 | baseDir: BaseDirectory.AppData,
164 | }))
165 | ) {
166 | await mkdir(`cert/${hostname}`, {
167 | baseDir: BaseDirectory.AppData,
168 | recursive: true,
169 | });
170 | }
171 |
172 | // write public
173 | await writeTextFile(`cert/${hostname}/public.crt`, pems.public, {
174 | baseDir: BaseDirectory.AppData,
175 | });
176 |
177 | // write key
178 | await writeTextFile(`cert/${hostname}/private.key`, pems.private, {
179 | baseDir: BaseDirectory.AppData,
180 | });
181 |
182 | // write cert
183 | await writeTextFile(`cert/${hostname}/cert.pem`, pems.cert, {
184 | baseDir: BaseDirectory.AppData,
185 | });
186 |
187 | return pems;
188 | }
189 | }
190 |
--------------------------------------------------------------------------------
/src/helpers/file-manager/index.ts:
--------------------------------------------------------------------------------
1 | import { BaseDirectory } from "@tauri-apps/plugin-fs";
2 |
3 | export interface IFileManagerBase {
4 | boot: () => Promise;
5 | getBaseDir: () => BaseDirectory;
6 | migrate: () => Promise;
7 | getProxies: () => Promise;
8 | saveProxies: (data: any) => Promise;
9 | }
10 |
--------------------------------------------------------------------------------
/src/helpers/proxy-manager/constants.ts:
--------------------------------------------------------------------------------
1 | export const CONFIG_DIR = "Config";
2 |
3 | export const PROXY_FILE_NAME = "app.endpoint.json";
4 | export const GROUP_FILE_NAME = "app.endpoint.group.json";
5 | export const DEFAULT_PROXY_GROUP_ID = "all-proxies";
6 | export const DEFAULT_PROXY_GROUP_NAME = "All ✨";
7 |
--------------------------------------------------------------------------------
/src/helpers/proxy-manager/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BaseDirectory,
3 | exists,
4 | mkdir,
5 | readTextFile,
6 | writeTextFile,
7 | } from "@tauri-apps/plugin-fs";
8 | import { IFileManagerBase } from "../file-manager";
9 | import { CONFIG_DIR, GROUP_FILE_NAME, PROXY_FILE_NAME } from "./constants";
10 | import { IProxyData, IProxyGroupData } from "./interfaces";
11 | import { m001_createGroupIfNotExists } from "./migration/001-create-group";
12 | import { m002_addProxyCreatedAt } from "./migration/002-add-proxy-created-at";
13 |
14 | let mgr: ProxyManager | undefined = undefined;
15 |
16 | export class ProxyManager implements IFileManagerBase {
17 | constructor() { }
18 |
19 | static sharedManager(): ProxyManager {
20 | if (!mgr) {
21 | mgr = new ProxyManager();
22 | }
23 | return mgr;
24 | }
25 |
26 | getBaseDir() {
27 | return BaseDirectory.AppData;
28 | }
29 |
30 | async boot() {
31 | const baseDir = this.getBaseDir();
32 | const dirExist = await exists(CONFIG_DIR, { baseDir });
33 | if (!dirExist) {
34 | await mkdir(CONFIG_DIR, { baseDir, recursive: true });
35 | }
36 | // create file if not exist
37 | const fileExist = await exists(`${CONFIG_DIR}/${PROXY_FILE_NAME}`, { baseDir });
38 | if (!fileExist) {
39 | await writeTextFile(
40 | `${CONFIG_DIR}/${PROXY_FILE_NAME}`,
41 | JSON.stringify([]),
42 | {
43 | baseDir,
44 | }
45 | );
46 | }
47 |
48 | await this.migrate();
49 | }
50 |
51 | async migrate() {
52 | await m001_createGroupIfNotExists(mgr!);
53 | await m002_addProxyCreatedAt(mgr!);
54 | return true;
55 | }
56 |
57 | async getProxies() {
58 | const baseDir = this.getBaseDir();
59 | const fileData = await readTextFile(`${CONFIG_DIR}/${PROXY_FILE_NAME}`, {
60 | baseDir,
61 | });
62 | const endpointList = JSON.parse(fileData) as IProxyData[];
63 | return endpointList;
64 | }
65 |
66 | async getGroups() {
67 | const baseDir = this.getBaseDir();
68 | const fileData = await readTextFile(`${CONFIG_DIR}/${GROUP_FILE_NAME}`, {
69 | baseDir,
70 | });
71 | const groupList = JSON.parse(fileData) as IProxyGroupData[];
72 | return groupList;
73 | }
74 |
75 | async saveGroups(data: IProxyGroupData[]) {
76 | const baseDir = this.getBaseDir();
77 |
78 | const cleaned: IProxyGroupData[] = data.map((d) => {
79 | const cleanedincludedHosts = d.includedHosts.map((p) => {
80 | if (typeof p === "object") {
81 | return (p as IProxyData).hostname;
82 | }
83 | return p;
84 | });
85 | return {
86 | ...d,
87 | updatedAt: new Date().toISOString(),
88 | includedHosts: cleanedincludedHosts,
89 | };
90 | });
91 | await writeTextFile(
92 | `${CONFIG_DIR}/${GROUP_FILE_NAME}`,
93 | JSON.stringify(cleaned),
94 | {
95 | baseDir,
96 | }
97 | );
98 | }
99 |
100 | async saveProxies(data: any) {
101 | const baseDir = this.getBaseDir();
102 | await writeTextFile(
103 | `${CONFIG_DIR}/${PROXY_FILE_NAME}`,
104 | JSON.stringify(data),
105 | {
106 | baseDir,
107 | }
108 | );
109 | }
110 |
111 | async getProxyInGroup(groupName: string) {
112 | const groups = await this.getGroups();
113 | const proxies = await this.getProxies();
114 | const identifiedGroup = groups.filter((p) => p.name === groupName);
115 | const mapped = identifiedGroup.map((g) => {
116 | return g.includedHosts.map((host) => {
117 | return proxies.find((p) => p.hostname === host);
118 | });
119 | });
120 |
121 | console.log(mapped);
122 | return mapped;
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/helpers/proxy-manager/interfaces.ts:
--------------------------------------------------------------------------------
1 | export type IProxyData = {
2 | nickname: string;
3 | hostname: string;
4 | port: number;
5 | createdAt: string;
6 | canLaunch?: boolean;
7 | };
8 |
9 | export type IProxyGroupData = {
10 | id: string;
11 | name: string;
12 | isNoGroup: boolean; // true if this is the total proxy list
13 | includedHosts: (string | IProxyData)[];
14 | createdAt: string;
15 | updatedAt: string;
16 | };
17 |
--------------------------------------------------------------------------------
/src/helpers/proxy-manager/migration/001-create-group.ts:
--------------------------------------------------------------------------------
1 | import { exists, mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
2 | import { ProxyManager } from "..";
3 | import {
4 | CONFIG_DIR,
5 | DEFAULT_PROXY_GROUP_ID,
6 | DEFAULT_PROXY_GROUP_NAME,
7 | GROUP_FILE_NAME,
8 | } from "../constants";
9 | import { IProxyGroupData } from "../interfaces";
10 |
11 | export async function m001_createGroupIfNotExists(mgrInstance: ProxyManager) {
12 | const baseDir = mgrInstance.getBaseDir();
13 | const dirExist = await exists(CONFIG_DIR, { baseDir });
14 | if (!dirExist) {
15 | await mkdir(CONFIG_DIR, { baseDir, recursive: true });
16 | }
17 | // create group file if not exists
18 | const fileExists = await exists(`${CONFIG_DIR}/${GROUP_FILE_NAME}`, { baseDir });
19 | if (!fileExists) {
20 | const defaultGroup: IProxyGroupData = {
21 | id: DEFAULT_PROXY_GROUP_ID,
22 | name: DEFAULT_PROXY_GROUP_NAME,
23 | includedHosts: [],
24 | isNoGroup: true,
25 | createdAt: new Date().toISOString(),
26 | updatedAt: new Date().toISOString(),
27 | };
28 | await writeTextFile(
29 | `${CONFIG_DIR}/${GROUP_FILE_NAME}`,
30 | JSON.stringify([defaultGroup]),
31 | {
32 | baseDir,
33 | }
34 | );
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/helpers/proxy-manager/migration/002-add-proxy-created-at.ts:
--------------------------------------------------------------------------------
1 | import { ProxyManager } from "..";
2 |
3 | export async function m002_addProxyCreatedAt(mgrInstance: ProxyManager) {
4 | const _proxyList = await mgrInstance.getProxies();
5 | _proxyList.map((proxy) => {
6 | if (!proxy.createdAt) {
7 | proxy.createdAt = new Date().toISOString();
8 | }
9 | });
10 | await mgrInstance.saveProxies(_proxyList);
11 | }
12 |
--------------------------------------------------------------------------------
/src/helpers/system/index.ts:
--------------------------------------------------------------------------------
1 | import { BaseDirectory } from "@tauri-apps/plugin-fs";
2 | import { ProxyManager } from "../proxy-manager";
3 |
4 | export const CONFIG_PATH = "Config";
5 | export const CONFIG_FILENAME = "app.config.json";
6 | export const CONFIG_ENDPOINTS_FILENAME = "app.endpoint.json";
7 |
8 | export class SystemHelper {
9 | private dir: BaseDirectory;
10 | constructor() {
11 | this.dir = BaseDirectory.AppData;
12 | }
13 |
14 | async boot() {
15 | const mgr = ProxyManager.sharedManager();
16 | await mgr.boot();
17 | await new Promise((resolve) => setTimeout(resolve, 100));
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/hooks/use-integer-height.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | /**
4 | * This hook will round the height of the element to the nearest integer.
5 | * @param ref The ref of the element to observe.
6 | */
7 | function useIntegerHeight(ref: React.RefObject) {
8 | const [height, setHeight] = React.useState(null);
9 |
10 | const adjustHeight = React.useCallback(() => {
11 | if (!ref.current) return;
12 |
13 | const element = ref.current;
14 | const currentHeight = element.getBoundingClientRect().height;
15 | const roundedHeight = Math.round(currentHeight);
16 |
17 | if (currentHeight !== roundedHeight) {
18 | element.style.height = `${roundedHeight}px`;
19 | setHeight(roundedHeight);
20 | }
21 | }, [ref]);
22 |
23 | // Initial measurement after mount and ref is available
24 | React.useLayoutEffect(() => {
25 | adjustHeight();
26 | }, [adjustHeight]);
27 |
28 | // Set up ResizeObserver for subsequent changes
29 | React.useEffect(() => {
30 | if (!ref.current) return;
31 |
32 | const element = ref.current;
33 | const resizeObserver = new ResizeObserver(() => {
34 | adjustHeight();
35 | });
36 |
37 | resizeObserver.observe(element);
38 | return () => resizeObserver.disconnect();
39 | }, [ref, adjustHeight]);
40 |
41 | return height;
42 | }
43 |
44 | export default useIntegerHeight;
--------------------------------------------------------------------------------
/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(undefined)
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12 | }
13 | mql.addEventListener("change", onChange)
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15 | return () => mql.removeEventListener("change", onChange)
16 | }, [])
17 |
18 | return !!isMobile
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const ICON_STROKE_WIDTH = 1.5;
2 | export const ICON_STROKE_WIDTH_SM = 1.2;
3 | export const ICON_SIZE = 14;
4 | export const ICON_SIZE_SM = 12;
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/stores/cert-keychain-store.ts:
--------------------------------------------------------------------------------
1 | import { invoke } from "@tauri-apps/api/core";
2 | import { appDataDir, BaseDirectory, homeDir } from "@tauri-apps/api/path";
3 | import { UnwatchFn, watch } from "@tauri-apps/plugin-fs";
4 | import { create } from "zustand";
5 |
6 | export interface Certificate {
7 | sha1: string;
8 | sha256: string;
9 | keychain: string;
10 | name: string;
11 | subject: string;
12 | attributes: Record;
13 | }
14 |
15 | export interface CertKeychainStore {
16 | watcher: UnwatchFn | null;
17 | certOnKeychain: Record;
18 | foundCertificates: Certificate[];
19 | homeDir: string;
20 | appDataDir: string;
21 | init: () => Promise;
22 | findExcatCertificateByName: (name: string) => Promise;
23 | checkCertExistOnKeychain: (name: string, shouldFetch?: boolean) => Promise;
24 | removeCertFromKeychain: (name: string) => Promise;
25 | removeCertBySha1: (sha1: string) => Promise;
26 | addCertToKeychain: (pemFilePath: string) => Promise;
27 | generateManualCommand: (name: string) => Promise;
28 | findCertificates: (name: string) => Promise;
29 | }
30 |
31 | // cache for 5 seconds
32 | const CACHE_TIME = 5000;
33 |
34 | function parseCertificateOutput(output: string): Certificate[] {
35 | const certificates: Certificate[] = [];
36 | const blocks = output.split('SHA-256 hash:').filter(block => block.trim());
37 |
38 | for (const block of blocks) {
39 | try {
40 | const lines = block.split('\n').map(line => line.trim());
41 | const cert: Partial = {
42 | attributes: {},
43 | };
44 |
45 | for (let i = 0; i < lines.length; i++) {
46 | const line = lines[i];
47 | if (line.startsWith('SHA-1 hash:')) {
48 | cert.sha1 = line.split(':')[1].trim();
49 | } else if (i === 0) { // First line is SHA-256
50 | cert.sha256 = line.trim();
51 | } else if (line.startsWith('keychain:')) {
52 | cert.keychain = line.split(':')[1].trim().replace(/"/g, '');
53 | } else if (line.includes('"alis"=')) {
54 | cert.name = line.split('=')[1].trim().replace(/"/g, '');
55 | } else if (line.includes('"subj"=')) {
56 | cert.subject = line.split('=')[1].trim().replace(/"/g, '');
57 | } else if (line.includes('=')) {
58 | const [key, value] = line.split('=');
59 | cert.attributes![key.trim().replace(/"/g, '')] = value.trim().replace(/"/g, '');
60 | }
61 | }
62 |
63 | if (cert.sha1 && cert.name) {
64 | certificates.push(cert as Certificate);
65 | }
66 | } catch (error) {
67 | console.error('Failed to parse certificate block:', error);
68 | }
69 | }
70 |
71 | return certificates;
72 | }
73 |
74 | export const certKeychainStore = create((set, get) => ({
75 | watcher: null,
76 | certOnKeychain: {},
77 | foundCertificates: [],
78 | homeDir: '',
79 | appDataDir: '',
80 | init: async () => {
81 | const homeDirPath = await homeDir();
82 | const appDataDirPath = await appDataDir();
83 | set({ homeDir: homeDirPath, appDataDir: appDataDirPath });
84 | if (get().watcher) {
85 | console.warn('Watcher already exists');
86 | // call unwatchFn
87 | get().watcher?.();
88 | }
89 | const unWatchFn = await watch('cert', (event) => {
90 | console.log('Event:', event);
91 | console.log(`Kind`, (event as any).kind);
92 | }, {
93 | baseDir: BaseDirectory.AppData,
94 | delayMs: 1000,
95 | recursive: true,
96 | });
97 |
98 | set({ watcher: unWatchFn });
99 | },
100 | findCertificates: async (name: string) => {
101 | try {
102 | const output = await invoke('find_certificates', { name });
103 | const certificates = parseCertificateOutput(output);
104 | set({ foundCertificates: certificates });
105 | return certificates;
106 | } catch (error) {
107 | console.error('Failed to find certificates:', error);
108 | return [];
109 | }
110 | },
111 | /**
112 | * Check if the certificate exists on the keychain.
113 | * @param name
114 | * @param shouldFetch
115 | * @returns
116 | */
117 | checkCertExistOnKeychain: async (name, shouldFetch = false) => {
118 | const now = Date.now();
119 | const cached = get().certOnKeychain[name];
120 |
121 | if (
122 | !shouldFetch &&
123 | cached &&
124 | now - cached.timestamp < CACHE_TIME
125 | ) {
126 | console.log(`Cached: ${name}`, cached.exists);
127 | return cached.exists;
128 | }
129 |
130 | const exists = await get().findExcatCertificateByName(name);
131 |
132 | set(state => ({
133 | ...state,
134 | certOnKeychain: {
135 | ...state.certOnKeychain,
136 | [name]: { exists: !!exists, timestamp: now }
137 | }
138 | }));
139 |
140 | return !!exists;
141 | },
142 |
143 | /**
144 | * Find exact certificate by name.
145 | * @param name
146 | * @returns
147 | */
148 | findExcatCertificateByName: async (name: string) => {
149 | const certificates = await get().findCertificates(name);
150 | return certificates.find(cert => cert.name === name);
151 | },
152 | /**
153 | * Remove requires the name of the certificate.
154 | * @param name
155 | */
156 | removeCertFromKeychain: async (name) => {
157 | const certificates = await get().findCertificates(name);
158 | const exactMatch = certificates.find(cert => cert.name === name);
159 |
160 | if (!exactMatch) {
161 | throw new Error(`Certificate not found: ${name}`);
162 | }
163 |
164 | await get().removeCertBySha1(exactMatch.sha1);
165 |
166 | set(state => ({
167 | ...state,
168 | certOnKeychain: {
169 | ...state.certOnKeychain,
170 | [name]: { exists: false, timestamp: Date.now() }
171 | }
172 | }));
173 | },
174 | removeCertBySha1: async (sha1: string) => {
175 | await invoke('remove_cert_by_sha1', { sha1 });
176 | },
177 | /**
178 | * Adding requires path to the pem file.
179 | * @param name
180 | */
181 | addCertToKeychain: async (name) => {
182 | const appDataDirPath = get().appDataDir;
183 | const pemFilePath = `${appDataDirPath}/cert/${name}/cert.pem`;
184 | await invoke('add_cert_to_keychain', {
185 | pem_file_path: pemFilePath,
186 | });
187 | },
188 | /**
189 | * Generate manual command to add the certificate to the keychain.
190 | * @param name
191 | * @returns
192 | */
193 | generateManualCommand: async (name) => {
194 | const homeDirectory = get().homeDir;
195 | const appDataDirPath = get().appDataDir;
196 |
197 | const keychainPath = `${homeDirectory}/Library/Keychains/login.keychain-db`;
198 | const pemFilePath = `${appDataDirPath}/cert/${name}/cert.pem`;
199 |
200 | const command = `security add-trusted-cert -k ${keychainPath} \"${pemFilePath}\"`;
201 | return command;
202 | }
203 | }));
204 |
--------------------------------------------------------------------------------
/src/stores/hosts-store.ts:
--------------------------------------------------------------------------------
1 | import { invoke } from "@tauri-apps/api/core";
2 | import { create } from "zustand";
3 |
4 | interface HostsStore {
5 | checkHostExists: (hostname: string) => Promise;
6 | addHostToFile: (hostname: string, password: string) => Promise;
7 | removeHostFromFile: (hostname: string, password: string) => Promise;
8 | }
9 |
10 | export const hostsStore = create()((set) => ({
11 | checkHostExists: async (hostname: string) => {
12 | try {
13 | const exists = await invoke("check_host_exists", { hostname });
14 | return exists;
15 | } catch (e) {
16 | console.error("Error checking host existence:", e);
17 | return false;
18 | }
19 | },
20 |
21 | addHostToFile: async (hostname: string, password: string) => {
22 | try {
23 | await invoke("add_line_to_hosts", { hostname, password });
24 | } catch (e) {
25 | console.error("Error adding host to file:", e);
26 | throw e;
27 | }
28 | },
29 |
30 | removeHostFromFile: async (hostname: string, password: string) => {
31 | try {
32 | await invoke("delete_line_from_hosts", { hostname, password });
33 | } catch (e) {
34 | console.error("Error removing host from file:", e);
35 | throw e;
36 | }
37 | },
38 | }));
--------------------------------------------------------------------------------
/src/stores/proxy-list.ts:
--------------------------------------------------------------------------------
1 | import { ProxyManager } from "@/helpers/proxy-manager";
2 | import { DEFAULT_PROXY_GROUP_ID } from "@/helpers/proxy-manager/constants";
3 | import {
4 | IProxyData,
5 | IProxyGroupData,
6 | } from "@/helpers/proxy-manager/interfaces";
7 | import { toast } from "sonner";
8 | import { create } from "zustand";
9 | import { subscribeWithSelector } from "zustand/middleware";
10 | interface ProxyListStore {
11 | totalProxyList: IProxyData[];
12 | proxyList: IProxyData[];
13 | groupList: IProxyGroupData[];
14 | selectedGroup: IProxyGroupData | null;
15 | load(): void;
16 | setProxyList: (proxyList: IProxyData[]) => void;
17 | updateProxyCanLaunch: (proxy: IProxyData, canLaunch: boolean) => void;
18 | deleteProxyFromList: (proxy: IProxyData) => void;
19 | addProxyItem: (data: IProxyData, group?: IProxyGroupData) => void;
20 | addGroup: (groupName: string) => void;
21 | deleteGroup: (groupId: string) => void;
22 | updateGroup: (group: IProxyGroupData) => void;
23 | addProxyToGroup: (proxy: IProxyData, group: IProxyGroupData) => void;
24 | removeProxyFromGroup: (proxy: IProxyData, group: IProxyGroupData) => void;
25 | setSelectedGroup: (group: IProxyGroupData) => void;
26 | }
27 |
28 | function filterProxyFromGroup(allList: IProxyData[], group: IProxyGroupData) {
29 | if (group.isNoGroup) {
30 | // All
31 | return allList;
32 | }
33 | return allList.filter((el) => {
34 | return group.includedHosts.find((e) => e === el.hostname);
35 | });
36 | }
37 |
38 | const proxyListStore = create()(
39 | subscribeWithSelector((set, get) => {
40 | return {
41 | totalProxyList: [],
42 | proxyList: [],
43 | groupList: [],
44 | selectedGroup: null,
45 | load: async () => {
46 | const mgr = ProxyManager.sharedManager();
47 | const list = await mgr.getProxies();
48 | const gList = await mgr.getGroups();
49 | console.log(`load`, list, gList);
50 | set({
51 | groupList: gList,
52 | totalProxyList: list,
53 | proxyList: list,
54 | });
55 | // if selectedGroup == null then set default group
56 | let selectedGroup = get().selectedGroup;
57 | if (!selectedGroup) {
58 | selectedGroup =
59 | gList.find((el) => el.id === DEFAULT_PROXY_GROUP_ID) ?? null;
60 | set({ selectedGroup });
61 | }
62 | },
63 | setProxyList: (proxyList: IProxyData[]) => set({ proxyList }),
64 | updateProxyCanLaunch: async (proxy: IProxyData, canLaunch: boolean) => {
65 | console.log(`updateProxyCanLaunch`, proxy, canLaunch);
66 | const _totalProxyList = get().totalProxyList;
67 | const index = _totalProxyList.findIndex(
68 | (el) => el.hostname === proxy.hostname
69 | );
70 | _totalProxyList[index].canLaunch = canLaunch;
71 | set({ totalProxyList: [..._totalProxyList] });
72 | },
73 | deleteProxyFromList: async (proxy: IProxyData) => {
74 | const _proxyList = get().totalProxyList;
75 | const _groupList = get().groupList;
76 | const index = _proxyList.findIndex(
77 | (el) => el.hostname === proxy.hostname
78 | );
79 | _proxyList.splice(index, 1);
80 | for (const group of _groupList) {
81 | group.includedHosts = group.includedHosts.filter(
82 | (el) => el !== proxy.hostname
83 | );
84 | }
85 | toast.success("Proxy Deleted");
86 | set({
87 | proxyList: [..._proxyList],
88 | totalProxyList: [..._proxyList],
89 | groupList: [..._groupList],
90 | });
91 | },
92 | addGroup: async (groupName: string) => {
93 | const newGroupData: IProxyGroupData = {
94 | isNoGroup: false,
95 | id: Math.random().toString(36).substring(7),
96 | name: groupName,
97 | includedHosts: [],
98 | createdAt: new Date().toISOString(),
99 | updatedAt: new Date().toISOString(),
100 | };
101 | const _groupList = get().groupList;
102 | set({
103 | groupList: [..._groupList, newGroupData],
104 | selectedGroup: newGroupData,
105 | });
106 | const filteredList = get().totalProxyList.filter((el) =>
107 | newGroupData.includedHosts.find((e) => e === el.hostname)
108 | );
109 | set({ proxyList: filteredList });
110 | toast.success("Group Created");
111 | },
112 | updateGroup: async (group: IProxyGroupData) => {
113 | const _groupList = get().groupList;
114 | const groupIndex = _groupList.findIndex((el) => el.id === group.id);
115 | group.updatedAt = new Date().toISOString();
116 | _groupList[groupIndex] = group;
117 | toast.success("Group Updated");
118 | set({ groupList: [..._groupList] });
119 | },
120 | deleteGroup: async (groupId: string) => {
121 | const _groupList = get().groupList;
122 | const index = _groupList.findIndex((el) => el.id === groupId);
123 | _groupList.splice(index, 1);
124 | set({ groupList: [..._groupList] });
125 | get().setSelectedGroup(_groupList[0]);
126 | toast.success("Group Deleted");
127 | },
128 | addProxyItem: async (data: IProxyData, group?: IProxyGroupData) => {
129 | const _proxyList = get().totalProxyList;
130 | if (_proxyList.find((e: IProxyData) => e.hostname === data.hostname)) {
131 | // same hostname already exists
132 | toast.error("Proxy with same hostname already exists");
133 | return;
134 | }
135 | // add to proxy list
136 | _proxyList.push(data);
137 | set({
138 | totalProxyList: [..._proxyList],
139 | });
140 | toast.success("Proxy Created");
141 | },
142 | addProxyToGroup: async (proxy: IProxyData, group: IProxyGroupData) => {
143 | const _groupList = get().groupList;
144 | const filterGroup = _groupList.find((el) => el.id === group.id);
145 | if (!filterGroup) {
146 | toast.error("Group not found");
147 | return;
148 | }
149 | if (filterGroup!.includedHosts.find((el) => el === proxy.hostname)) {
150 | // already exists
151 | toast.error("Proxy already exists in group");
152 | return;
153 | }
154 | filterGroup!.includedHosts.push(proxy.hostname);
155 | if (filterGroup.id === get().selectedGroup?.id) {
156 | const filteredList = filterProxyFromGroup(
157 | get().totalProxyList,
158 | filterGroup
159 | );
160 | set({ proxyList: filteredList });
161 | }
162 | set({ groupList: [..._groupList] });
163 | console.log(`addProxyToGroup`, proxy, _groupList);
164 | toast.success("Proxy Added to Group");
165 | },
166 | removeProxyFromGroup: async (
167 | proxy: IProxyData,
168 | group: IProxyGroupData
169 | ) => {
170 | const _groupList = get().groupList;
171 | const filterGroup = _groupList.find((el) => el.id === group.id);
172 | if (!filterGroup) {
173 | toast.error("Group not found");
174 | return;
175 | }
176 | const index = filterGroup!.includedHosts.findIndex(
177 | (el) => el === proxy.hostname
178 | );
179 | filterGroup!.includedHosts.splice(index, 1);
180 | if (filterGroup.id === get().selectedGroup?.id) {
181 | const filteredList = filterProxyFromGroup(
182 | get().totalProxyList,
183 | filterGroup
184 | );
185 | set({ proxyList: filteredList });
186 | }
187 | set({ groupList: [..._groupList] });
188 | toast.success("Proxy Removed from Group", {
189 | description: "Click Undo to add it back.",
190 | action: {
191 | label: "Undo",
192 | onClick: () => {
193 | get().addProxyToGroup(proxy, group);
194 | },
195 | },
196 | });
197 | },
198 | setSelectedGroup: async (group: IProxyGroupData) => {
199 | set({ selectedGroup: group });
200 | },
201 | };
202 | })
203 | );
204 |
205 | proxyListStore.subscribe(
206 | (state) => state.selectedGroup,
207 | async (selectedGroup) => {
208 | if (selectedGroup) {
209 | const totalProxyList = proxyListStore.getState().totalProxyList;
210 | const filteredList = filterProxyFromGroup(totalProxyList, selectedGroup);
211 | proxyListStore.setState({ proxyList: filteredList });
212 | }
213 | }
214 | );
215 |
216 | proxyListStore.subscribe(
217 | (state) => state.totalProxyList,
218 | async (totalProxyList) => {
219 | const get = proxyListStore.getState();
220 | const mgr = ProxyManager.sharedManager();
221 | await mgr.saveProxies(totalProxyList);
222 | if (get.selectedGroup) {
223 | const filteredList = filterProxyFromGroup(
224 | totalProxyList,
225 | get.selectedGroup
226 | );
227 | proxyListStore.setState({ proxyList: filteredList });
228 | }
229 | console.log(`totalProxyList saved`, totalProxyList);
230 | }
231 | );
232 |
233 | proxyListStore.subscribe(
234 | (state) => state.proxyList,
235 | async (proxyList) => {
236 | console.log(`proxyList updated`, proxyList);
237 | }
238 | );
239 |
240 | proxyListStore.subscribe(
241 | (state) => state.groupList,
242 | async (groupList) => {
243 | const mgr = ProxyManager.sharedManager();
244 | await mgr.saveGroups(groupList);
245 | console.log(`groupList saved`, groupList);
246 | }
247 | );
248 |
249 | export default proxyListStore;
250 |
--------------------------------------------------------------------------------
/src/stores/system-status.ts:
--------------------------------------------------------------------------------
1 | import { appDataDir } from "@tauri-apps/api/path";
2 | import { Command } from "@tauri-apps/plugin-shell";
3 | import { create } from "zustand";
4 |
5 | export interface DockerContainerStatus {
6 | containerInfo: IContainer | null;
7 | isRunning: boolean;
8 | error?: string;
9 | }
10 |
11 | export interface IContainer {
12 | State: string;
13 | Name: string;
14 | Project: string;
15 | }
16 |
17 | interface SystemStatusStore {
18 | isCheckDone: boolean;
19 | isDockerInstalled: boolean;
20 | isDockerContainerRunning: boolean;
21 | runningContainerInfo: IContainer | null;
22 | isEverythingOk: () => boolean;
23 | setIsCheckDone: (checking: boolean) => void;
24 | setIsDockerInstalled: (installed: boolean) => void;
25 | setIsDockerContainerRunning: (
26 | running: boolean,
27 | containerInfo: IContainer | null
28 | ) => void;
29 | updateDockerContainerStatus: () => Promise;
30 | checkDockerContainerStatus: (
31 | dockerComposePath: string
32 | ) => Promise;
33 | }
34 |
35 | const systemStatusStore = create((set, get) => ({
36 | isCheckDone: false,
37 | isDockerInstalled: false,
38 | isDockerContainerRunning: false,
39 | runningContainerInfo: null,
40 | isEverythingOk: () => {
41 | const { isCheckDone, isDockerInstalled } = get();
42 | return isCheckDone && isDockerInstalled;
43 | },
44 | setIsCheckDone: (checking) => set({ isCheckDone: checking }),
45 | setIsDockerInstalled: (installed) => set({ isDockerInstalled: installed }),
46 | setIsDockerContainerRunning: (running, containerInfo) =>
47 | set({
48 | isDockerContainerRunning: running,
49 | runningContainerInfo: containerInfo,
50 | }),
51 | updateDockerContainerStatus: async () => {
52 | const appDataDirPath = await appDataDir();
53 | const dockerComposePath = `${appDataDirPath}/docker-compose.yml`;
54 | const status = await get().checkDockerContainerStatus(dockerComposePath);
55 | set({
56 | isDockerContainerRunning: status.isRunning,
57 | runningContainerInfo: status.containerInfo,
58 | });
59 | return status;
60 | },
61 | checkDockerContainerStatus: async (
62 | dockerComposePath: string
63 | ): Promise => {
64 | try {
65 | // Execute docker compose ps command
66 | const command = Command.create("check-docker-container", [
67 | "compose",
68 | "-f",
69 | dockerComposePath,
70 | "ps",
71 | "--format",
72 | "json", // Use JSON format for easier parsing
73 | ]);
74 |
75 | const result = await command.execute();
76 |
77 | if (result.code !== 0) {
78 | console.log(
79 | `Docker command failed with status ${result.code}: ${result.stderr}`
80 | );
81 | return {
82 | containerInfo: null,
83 | isRunning: false,
84 | error: `Docker command failed with status ${result.code}: ${result.stderr}`,
85 | };
86 | }
87 |
88 | // Parse the JSON output
89 | const containers = JSON.parse(result.stdout);
90 | // console.log(containers);
91 | // Check if any container is running
92 | // Docker compose ps returns an array of containers with their states
93 | const runningContainers = containers.filter(
94 | (container: any) =>
95 | container.State === "running" && container.Name === "ophiuchi-nginx"
96 | );
97 |
98 | return {
99 | containerInfo:
100 | runningContainers.length > 0 ? runningContainers[0] : null,
101 | isRunning: runningContainers.length > 0,
102 | };
103 | } catch (error) {
104 | return {
105 | containerInfo: null,
106 | isRunning: false,
107 | error:
108 | error instanceof Error ? error.message : "Unknown error occurred",
109 | };
110 | }
111 | },
112 | }));
113 |
114 | export default systemStatusStore;
115 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{ts,tsx}",
7 | "./components/**/*.{ts,tsx}",
8 | "./app/**/*.{ts,tsx}",
9 | "./src/**/*.{ts,tsx}",
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | fontSize: {
22 | sm: "0.825rem",
23 | xs: "0.70rem",
24 | },
25 | colors: {
26 | background: "hsl(var(--background))",
27 | foreground: "hsl(var(--foreground))",
28 | card: "hsl(var(--card))",
29 | "card-foreground": "hsl(var(--card-foreground))",
30 | popover: "hsl(var(--popover))",
31 | "popover-foreground": "hsl(var(--popover-foreground))",
32 | primary: "hsl(var(--primary))",
33 | "primary-foreground": "hsl(var(--primary-foreground))",
34 | secondary: "hsl(var(--secondary))",
35 | "secondary-foreground": "hsl(var(--secondary-foreground))",
36 | muted: "hsl(var(--muted))",
37 | "muted-foreground": "hsl(var(--muted-foreground))",
38 | accent: "hsl(var(--accent))",
39 | "accent-foreground": "hsl(var(--accent-foreground))",
40 | destructive: "hsl(var(--destructive))",
41 | "destructive-foreground": "hsl(var(--destructive-foreground))",
42 | border: "hsl(var(--border))",
43 | input: "hsl(var(--input))",
44 | ring: "hsl(var(--ring))",
45 | radius: "hsl(var(--radius))",
46 | "chart-1": "hsl(var(--chart-1))",
47 | "chart-2": "hsl(var(--chart-2))",
48 | "chart-3": "hsl(var(--chart-3))",
49 | "chart-4": "hsl(var(--chart-4))",
50 | "chart-5": "hsl(var(--chart-5))",
51 | sidebar: {
52 | DEFAULT: "hsl(var(--sidebar-background))",
53 | foreground: "hsl(var(--sidebar-foreground))",
54 | primary: "hsl(var(--sidebar-primary))",
55 | "primary-foreground": "hsl(var(--sidebar-primary-foreground))",
56 | accent: "hsl(var(--sidebar-accent))",
57 | "accent-foreground": "hsl(var(--sidebar-accent-foreground))",
58 | border: "hsl(var(--sidebar-border))",
59 | ring: "hsl(var(--sidebar-ring))",
60 | },
61 | },
62 | keyframes: {
63 | "accordion-down": {
64 | from: {
65 | height: "0",
66 | },
67 | to: {
68 | height: "var(--radix-accordion-content-height)",
69 | },
70 | },
71 | "accordion-up": {
72 | from: {
73 | height: "var(--radix-accordion-content-height)",
74 | },
75 | to: {
76 | height: "0",
77 | },
78 | },
79 | },
80 | animation: {
81 | "accordion-down": "accordion-down 0.2s ease-out",
82 | "accordion-up": "accordion-up 0.2s ease-out",
83 | },
84 | },
85 | },
86 | plugins: [require("tailwindcss-animate")],
87 | } satisfies Config;
88 |
89 | export default config;
90 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "bundler",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./src/*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/update-check-v1/latest.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "v0.5.1",
3 | "notes": "0.5.1 Stable",
4 | "pub_date": "2024-06-06T00:30:00Z",
5 | "platforms": {
6 | "darwin-x86_64": {
7 | "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRUUhPeXZIazkyQStxTXV4YkdkSjJnOGNnbW9FQ2Y1QVVLVm1XL3g1N2ZWa1RBTE1TT1pnTFVTRVdtNitXQ0NudkZ3WlJ6Sjkrdms1SlJXK0xzV2RjVXh3NHdEQlNHc2cwPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzE3NjMzNjA4CWZpbGU6T3BoaXVjaGkuYXBwLnRhci5negpoaWpEVVZCSllFSXdJS0FSSktqT09YSDRNem5JcmU2QjkrYzFoRkQ2bnh0RGw1RHc2dXlPNTlFanN3aE5rM3VhMDBWTEVWNEhPQStndGlIWGdsT3BEUT09Cg==",
8 | "url": "https://release.ophiuchi.dev/stable/update/v0.5.1/Ophiuchi.app.tar.gz"
9 | },
10 | "darwin-aarch64": {
11 | "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRUUhPeXZIazkyQStxTXV4YkdkSjJnOGNnbW9FQ2Y1QVVLVm1XL3g1N2ZWa1RBTE1TT1pnTFVTRVdtNitXQ0NudkZ3WlJ6Sjkrdms1SlJXK0xzV2RjVXh3NHdEQlNHc2cwPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzE3NjMzNjA4CWZpbGU6T3BoaXVjaGkuYXBwLnRhci5negpoaWpEVVZCSllFSXdJS0FSSktqT09YSDRNem5JcmU2QjkrYzFoRkQ2bnh0RGw1RHc2dXlPNTlFanN3aE5rM3VhMDBWTEVWNEhPQStndGlIWGdsT3BEUT09Cg==",
12 | "url": "https://release.ophiuchi.dev/stable/update/v0.5.1/Ophiuchi.app.tar.gz"
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
|