Repository: lencx/ChatGPT
Branch: v2-dev
Commit: a6de9a8b6107
Files: 55
Total size: 56.4 KB
Directory structure:
gitextract_vymxshi2/
├── .gitignore
├── .vscode/
│ └── extensions.json
├── Cargo.toml
├── README.md
├── index.html
├── package.json
├── postcss.config.js
├── rustfmt.toml
├── src/
│ ├── App.tsx
│ ├── base.css
│ ├── components/
│ │ └── WinTitlebar.tsx
│ ├── hooks/
│ │ ├── useInfo.tsx
│ │ └── useTheme.tsx
│ ├── icons/
│ │ ├── ArrowLeft.tsx
│ │ ├── Ask.tsx
│ │ ├── Link.tsx
│ │ ├── Pin.tsx
│ │ ├── Reload.tsx
│ │ ├── SVGWrap.tsx
│ │ ├── Send.tsx
│ │ ├── Setting.tsx
│ │ ├── ThemeDark.tsx
│ │ ├── ThemeLight.tsx
│ │ ├── ThemeSystem.tsx
│ │ ├── UnPin.tsx
│ │ ├── WindowClose.tsx
│ │ ├── WindowMaximize.tsx
│ │ ├── WindowMinimize.tsx
│ │ └── WindowRestore.tsx
│ ├── main.tsx
│ ├── types.d.ts
│ ├── view/
│ │ ├── Ask.tsx
│ │ ├── Settings.tsx
│ │ └── Titlebar.tsx
│ └── vite-env.d.ts
├── src-tauri/
│ ├── .gitignore
│ ├── Cargo.toml
│ ├── Info.plist
│ ├── build.rs
│ ├── capabilities/
│ │ └── desktop.json
│ ├── icons/
│ │ └── icon.icns
│ ├── scripts/
│ │ └── ask.js
│ ├── src/
│ │ ├── core/
│ │ │ ├── cmd.rs
│ │ │ ├── conf.rs
│ │ │ ├── constant.rs
│ │ │ ├── mod.rs
│ │ │ ├── setup.rs
│ │ │ ├── template.rs
│ │ │ └── window.rs
│ │ └── main.rs
│ └── tauri.conf.json
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# rust
target/
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
================================================
FILE: .vscode/extensions.json
================================================
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}
================================================
FILE: Cargo.toml
================================================
[workspace]
resolver = "2"
members = ["src-tauri"]
# https://v2.tauri.app/test/webdriver/example/#adding-tauri-to-the-cargo-project
[profile.release]
incremental = false
codegen-units = 1
panic = "abort"
opt-level = "s"
lto = true
================================================
FILE: README.md
================================================
ChatGPT Desktop Application (Available on Mac, Windows, and Linux)
[](https://github.com/lencx/ChatGPT/releases)
[](https://discord.gg/aPhCRf4zZr)
[](https://twitter.com/lencx_)
[](https://www.youtube.com/@lencx)
---
> [!NOTE]
> **If you want to experience a more powerful AI wrapper application, you can try the Noi (https://github.com/lencx/Noi), which is a successor to the ChatGPT desktop application concept.**
Thank you very much for your interest in this project. OpenAI has now released the macOS version of the application, and a Windows version will be available later ([Introducing GPT-4o and more tools to ChatGPT free users](https://openai.com/index/gpt-4o-and-more-tools-to-chatgpt-free/)). If you prefer the official application, you can stay updated with the latest information from OpenAI.
If you want to learn about or download the previous version (v1.1.0), please click [here](https://github.com/lencx/ChatGPT/tree/release-v1.1.0).
I am currently looking for some differentiating features to develop version 2.0. If you are interested in this, please stay tuned.

================================================
FILE: index.html
================================================
ChatGPT
================================================
FILE: package.json
================================================
{
"name": "chatgpt",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "2.0.0-beta.15",
"@tauri-apps/plugin-os": "2.0.0-beta.7",
"@tauri-apps/plugin-shell": "2.0.0-beta.8",
"clsx": "^2.1.1",
"lodash": "^4.17.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.5.0",
"react-router-dom": "^6.23.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.13",
"@tauri-apps/cli": "2.0.0-beta.22",
"@types/lodash": "^4.17.1",
"@types/node": "^20.12.12",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vite-tsconfig-paths": "^4.3.2"
}
}
================================================
FILE: postcss.config.js
================================================
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
================================================
FILE: rustfmt.toml
================================================
max_width = 100
hard_tabs = false
tab_spaces = 4
newline_style = "Auto"
use_small_heuristics = "Default"
reorder_imports = true
reorder_modules = true
remove_nested_parens = true
edition = "2021"
merge_derives = true
use_try_shorthand = false
use_field_init_shorthand = false
force_explicit_abi = true
# imports_granularity = "Crate"
================================================
FILE: src/App.tsx
================================================
import { getCurrentWebview } from '@tauri-apps/api/webview';
import Titlebar from '~view/Titlebar';
import Ask from '~view/Ask';
import Settings from '~view/Settings';
const viewMap = {
titlebar: ,
ask: ,
settings: ,
};
export default function App() {
const webview = getCurrentWebview();
return viewMap[webview.label as keyof typeof viewMap] || null;
}
================================================
FILE: src/base.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #root {
margin: 0;
bottom: 0;
height: 100%;
overflow: hidden;
}
================================================
FILE: src/components/WinTitlebar.tsx
================================================
import { useEffect, useState } from 'react';
import { Window } from '@tauri-apps/api/window';
import WindowClose from '~icons/WindowClose';
import WindowMaximize from '~icons/WindowMaximize';
import WindowMinimize from '~icons/WindowMinimize';
import WindowRestore from '~/icons/WindowRestore';
export default function WinTitlebar() {
const [isMax, setMax] = useState(false);
useEffect(() => {
(async () => {
const win = Window.getByLabel('core');
const max = await win?.isMaximized();
setMax(!!max);
})()
}, [])
const handleToggle = async () => {
const win = Window.getByLabel('core');
await win?.toggleMaximize();
setMax(!isMax);
}
const handleMinimize = () => {
const win = Window.getByLabel('core');
win?.minimize();
};
const handleClose = () => {
const win = Window.getByLabel('core');
win?.close();
}
return (
{isMax
?
: }
)
}
================================================
FILE: src/hooks/useInfo.tsx
================================================
import { useEffect, useState } from 'react';
import { platform as TauriPlatform } from '@tauri-apps/plugin-os';
export default function useInfo() {
const [platform, setPlatform] = useState('');
const [isMac, setMac] = useState(false);
const handleInfo = async () => {
const p = await TauriPlatform();
setPlatform(await TauriPlatform());
setMac(p === 'macos');
}
useEffect(() => {
handleInfo();
}, []);
return { platform, isMac };
}
================================================
FILE: src/hooks/useTheme.tsx
================================================
import { useState, useEffect } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
export default function useTheme() {
const [theme, setTheme] = useState('light'); // ['light', 'dark']
useEffect(() => {
let unlisten: Function;
(async () => {
let win = getCurrentWindow();
setTheme(await win.theme() || '');
unlisten = await win.onThemeChanged(({ payload: newTheme }) => {
setTheme(newTheme);
});
})()
return () => {
unlisten?.();
};
}, [])
return theme;
}
================================================
FILE: src/icons/ArrowLeft.tsx
================================================
import SVGWrap from './SVGWrap';
export default function ArrowLeft(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/Ask.tsx
================================================
import SVGWrap from './SVGWrap';
export default function Ask(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/Link.tsx
================================================
import SVGWrap from './SVGWrap';
export default function Link(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/Pin.tsx
================================================
import SVGWrap from './SVGWrap';
export default function Pin(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/Reload.tsx
================================================
import SVGWrap from './SVGWrap';
export default function Reload(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/SVGWrap.tsx
================================================
import React from 'react';
import clsx from 'clsx';
export default function SVGWrap({ size = 18, children, type, className, title, onClick, action = false, ...props }: I.SVG) {
const handleClick = (e: React.MouseEvent) => {
onClick && onClick(e);
};
return (
);
}
================================================
FILE: src/icons/Send.tsx
================================================
import SVGWrap from './SVGWrap';
export default function Send(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/Setting.tsx
================================================
import SVGWrap from './SVGWrap';
export default function Setting(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/ThemeDark.tsx
================================================
import SVGWrap from './SVGWrap';
export default function ThemeDark(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/ThemeLight.tsx
================================================
import SVGWrap from './SVGWrap';
export default function ThemeLight(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/ThemeSystem.tsx
================================================
import SVGWrap from './SVGWrap';
export default function Ask(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/UnPin.tsx
================================================
import SVGWrap from './SVGWrap';
export default function UnPin(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/WindowClose.tsx
================================================
import SVGWrap from './SVGWrap';
export default function WindowClose(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/WindowMaximize.tsx
================================================
import SVGWrap from './SVGWrap';
export default function WindowMaximize(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/WindowMinimize.tsx
================================================
import SVGWrap from './SVGWrap';
export default function WindowMinimize(props: I.SVG) {
return (
);
}
================================================
FILE: src/icons/WindowRestore.tsx
================================================
import SVGWrap from './SVGWrap';
export default function WindowRestore(props: I.SVG) {
return (
);
}
================================================
FILE: src/main.tsx
================================================
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
// import Routes from './routes';
import App from './App';
import './base.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
);
================================================
FILE: src/types.d.ts
================================================
declare namespace I {
export type AppConf = {
theme: 'light' | 'dark' | 'system';
stay_on_top: boolean;
ask_mode: boolean;
mac_titlebar_hidden: boolean;
}
export interface SVG extends React.SVGProps {
children?: React.ReactNode;
size?: number;
title?: string;
action?: boolean;
onClick?: (e: React.MouseEvent) => void;
}
}
================================================
FILE: src/view/Ask.tsx
================================================
import { useState, useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useHotkeys } from 'react-hotkeys-hook';
import useInfo from '~hooks/useInfo';
import SendIcon from '~icons/Send';
import debounce from 'lodash/debounce';
export default function ChatInput() {
const inputRef = useRef(null);
const [message, setMessage] = useState('');
const { isMac } = useInfo();
useEffect(() => {
const syncMessage = debounce(async () => {
try {
await invoke('ask_sync', { message: JSON.stringify(message) });
} catch (error) {
console.error('Error syncing message:', error);
}
}, 300); // Debounce by 300ms
syncMessage();
return () => syncMessage.cancel(); // Cleanup debounce on unmount
}, [message]);
useHotkeys(isMac ? 'meta+enter' : 'ctrl+enter', async (event: KeyboardEvent) => {
event.preventDefault();
await handleSend();
}, {
enableOnFormTags: true,
}, [message]);
const handleInput = (e: React.ChangeEvent) => {
setMessage(e.target.value);
};
const handleSend = async () => {
if (!message) return;
try {
await invoke('ask_send', { message: JSON.stringify(message) });
} catch (error) {
console.error('Error sending message:', error);
}
setMessage('');
if (inputRef.current) {
inputRef.current.value = '';
inputRef.current.focus();
}
};
return (
);
}
================================================
FILE: src/view/Settings.tsx
================================================
export default function Settings() {
return (
Settings
)
}
================================================
FILE: src/view/Titlebar.tsx
================================================
import { useEffect, useState, useMemo } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { open } from '@tauri-apps/plugin-shell';
import { debounce } from 'lodash';
import clsx from 'clsx';
import useInfo from '~hooks/useInfo';
import ReloadIcon from '~icons/Reload';
import PinIcon from '~icons/Pin';
import UnPinIcon from '~icons/UnPin';
import LinkIcon from '~icons/Link';
import AskIcon from '~icons/Ask';
import SettingIcon from '~icons/Setting';
import ThemeSystem from '~icons/ThemeSystem';
import ThemeLight from '~icons/ThemeLight';
import ThemeDark from '~icons/ThemeDark';
import ArrowLeftIcon from '~icons/ArrowLeft';
export default function Titlebar() {
const info = useInfo();
const [url, setUrl] = useState('');
const [hostname, setHostname] = useState('');
const [theme, setTheme] = useState('system');
const [enableAsk, setEnableAsk] = useState(false);
const [fullScreen, setFullScreen] = useState(false);
const [isPin, setPin] = useState(false);
const [isTitlebarHidden, setTitlebarHidden] = useState(false);
const titlebarHidden = info.isMac && isTitlebarHidden;
useEffect(() => {
const win = getCurrentWindow();
let winResize: Function;
let changeUrl: Function;
invoke('get_app_conf')
.then((v) => {
setEnableAsk(v.ask_mode);
setPin(v.stay_on_top);
setTheme(v.theme);
setTitlebarHidden(v.mac_titlebar_hidden);
});
(async () => {
const full = await win.isFullscreen();
setFullScreen(full);
winResize = await win.listen('tauri://resize', debounce(async () => {
const full = await win.isFullscreen();
setFullScreen(full);
}, 50))
changeUrl = await getCurrentWindow().listen('navigation:change', (event: any) => {
const { url } = event.payload;
setUrl(url);
try {
const { hostname } = new URL(url);
setHostname(hostname);
} catch (error) {
setHostname(url);
}
})
})();
return () => {
winResize && winResize();
changeUrl && changeUrl();
}
}, [])
const handleRefresh = () => {
invoke('view_reload');
};
const handleGoForward = () => {
invoke('view_go_forward');
};
const handleGoBack = () => {
invoke('view_go_back');
};
const handlePin = (isPin: boolean) => {
setPin(isPin);
invoke('window_pin', { pin: isPin });
};
const handleAsk = () => {
setEnableAsk(!enableAsk);
invoke('set_view_ask', { enabled: !enableAsk });
};
const handleTheme = (theme: string) => {
invoke('set_theme', { theme });
};
const themeIcon = useMemo(() => {
switch (theme) {
case 'system':
return handleTheme('light')} />
case 'light':
return handleTheme('dark')} />
case 'dark':
return handleTheme('system')} />
default:
return handleTheme('system')} />
}
}, [theme]);
const handleOpenUrl = () => {
open(url);
};
const handleSetting = () => {
invoke('open_settings');
};
const renderSettings = useMemo(() => {
return (
{themeIcon}
{isPin
?
handlePin(false)} />
: handlePin(true)} />}
)
}, [titlebarHidden, themeIcon, isPin])
return (
{hostname && (
{hostname}
)}
{renderSettings}
);
}
================================================
FILE: src/vite-env.d.ts
================================================
///
================================================
FILE: src-tauri/.gitignore
================================================
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
================================================
FILE: src-tauri/Cargo.toml
================================================
[package]
name = "chatgpt"
version = "0.0.0"
description = "ChatGPT Desktop Application (Unofficial)"
authors = ["lencx "]
repository = "https://github.com/lencx/ChatGPT"
license = "AGPL-3.0"
edition = "2021"
rust-version = "1.77.1"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2.0.0-beta", features = [] }
[dependencies]
tauri = { version = "2.0.0-beta", features = ["unstable", "devtools"] }
tokio = { version = "1.37.0", features = ["macros"] }
tauri-plugin-shell = "2.0.0-beta"
tauri-plugin-dialog = "2.0.0-beta"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
once_cell = "1.19.0"
log = "0.4.21"
anyhow = "1.0.83"
dark-light = "1.1.1"
regex = "1.10.4"
semver = "1.0.23"
tauri-plugin-os = "2.0.0-beta.4"
================================================
FILE: src-tauri/Info.plist
================================================
NSAppTransportSecurity
NSExceptionDomains
chatgpt.com
NSExceptionAllowsInsecureHTTPLoads
NSIncludesSubdomains
================================================
FILE: src-tauri/build.rs
================================================
// src-tauri/build.rs
fn main() {
println!("cargo:rustc-env=MACOSX_DEPLOYMENT_TARGET=10.13");
tauri_build::build()
}
================================================
FILE: src-tauri/capabilities/desktop.json
================================================
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability",
"windows": [
"*"
],
"remote": {
"urls": [
"https://chatgpt.com/*"
]
},
"platforms": [
"linux",
"macOS",
"windows"
],
"permissions": [
"window:default",
"window:allow-create",
"window:allow-start-dragging",
"window:allow-toggle-maximize",
"window:allow-minimize",
"window:allow-close",
"webview:default",
"webview:allow-internal-toggle-devtools",
"webview:allow-set-webview-zoom",
"webview:allow-create-webview",
"webview:allow-create-webview-window",
"webview:allow-set-webview-focus",
"event:default",
"event:allow-emit",
"event:allow-emit-to",
"event:allow-listen",
"event:allow-unlisten",
"shell:default",
"shell:allow-execute",
"shell:allow-open",
"os:allow-arch",
"os:allow-platform",
"os:allow-version",
"os:allow-os-type",
"os:default",
"shell:default"
]
}
================================================
FILE: src-tauri/scripts/ask.js
================================================
/**
* @name ask.js
* @version 0.1.0
* @url https://github.com/lencx/ChatGPT/tree/main/scripts/ask.js
*/
class ChatAsk {
static sync(message) {
const inputElement = document.querySelector('textarea');
if (inputElement) {
const nativeTextareaSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
nativeTextareaSetter.call(inputElement, message);
const inputEvent = new InputEvent('input', {
bubbles: true,
cancelable: true,
});
inputElement.dispatchEvent(inputEvent);
}
}
static submit() {
const btns = document.querySelectorAll('main form button');
const btn = btns[btns.length - 1];
if (btn) {
btn.focus();
btn.disabled = false;
btn.click();
}
}
}
window.ChatAsk = ChatAsk;
================================================
FILE: src-tauri/src/core/cmd.rs
================================================
use tauri::{command, AppHandle, LogicalPosition, Manager, PhysicalSize};
use crate::core::{
conf::AppConf,
constant::{ASK_HEIGHT, TITLEBAR_HEIGHT},
};
#[command]
pub fn view_reload(app: AppHandle) {
app.get_window("core")
.unwrap()
.get_webview("main")
.unwrap()
.eval("window.location.reload()")
.unwrap();
}
#[command]
pub fn view_url(app: AppHandle) -> tauri::Url {
app.get_window("core")
.unwrap()
.get_webview("main")
.unwrap()
.url()
.unwrap()
}
#[command]
pub fn view_go_forward(app: AppHandle) {
app.get_window("core")
.unwrap()
.get_webview("main")
.unwrap()
.eval("window.history.forward()")
.unwrap();
}
#[command]
pub fn view_go_back(app: AppHandle) {
app.get_window("core")
.unwrap()
.get_webview("main")
.unwrap()
.eval("window.history.back()")
.unwrap();
}
#[command]
pub fn window_pin(app: AppHandle, pin: bool) {
let conf = AppConf::load(&app).unwrap();
conf.amend(serde_json::json!({"stay_on_top": pin}))
.unwrap()
.save(&app)
.unwrap();
app.get_window("core")
.unwrap()
.set_always_on_top(pin)
.unwrap();
}
#[command]
pub fn ask_sync(app: AppHandle, message: String) {
app.get_window("core")
.unwrap()
.get_webview("main")
.unwrap()
.eval(&format!("ChatAsk.sync({})", message))
.unwrap();
}
#[command]
pub fn ask_send(app: AppHandle) {
let win = app.get_window("core").unwrap();
win.get_webview("main")
.unwrap()
.eval(
r#"
ChatAsk.submit();
setTimeout(() => {
__TAURI__.webview.Webview.getByLabel('ask')?.setFocus();
}, 500);
"#,
)
.unwrap();
}
#[command]
pub fn set_theme(app: AppHandle, theme: String) {
let conf = AppConf::load(&app).unwrap();
conf.amend(serde_json::json!({"theme": theme}))
.unwrap()
.save(&app)
.unwrap();
app.restart();
}
#[command]
pub fn get_app_conf(app: AppHandle) -> AppConf {
AppConf::load(&app).unwrap()
}
#[command]
pub fn set_view_ask(app: AppHandle, enabled: bool) {
let conf = AppConf::load(&app).unwrap();
conf.amend(serde_json::json!({"ask_mode": enabled}))
.unwrap()
.save(&app)
.unwrap();
let core_window = app.get_window("core").unwrap();
let ask_mode_height = if enabled { ASK_HEIGHT } else { 0.0 };
let scale_factor = core_window.scale_factor().unwrap();
let titlebar_height = (scale_factor * TITLEBAR_HEIGHT).round() as u32;
let win_size = core_window.inner_size().unwrap();
let ask_height = (scale_factor * ask_mode_height).round() as u32;
let main_view = core_window.get_webview("main").unwrap();
let titlebar_view = core_window.get_webview("titlebar").unwrap();
let ask_view = core_window.get_webview("ask").unwrap();
if enabled {
ask_view.set_focus().unwrap();
} else {
main_view.set_focus().unwrap();
}
let set_view_properties =
|view: &tauri::Webview, position: LogicalPosition, size: PhysicalSize| {
if let Err(e) = view.set_position(position) {
eprintln!("[cmd:view:position] Failed to set view position: {}", e);
}
if let Err(e) = view.set_size(size) {
eprintln!("[cmd:view:size] Failed to set view size: {}", e);
}
};
#[cfg(target_os = "macos")]
{
set_view_properties(
&main_view,
LogicalPosition::new(0.0, TITLEBAR_HEIGHT),
PhysicalSize::new(
win_size.width,
win_size.height - (titlebar_height + ask_height),
),
);
set_view_properties(
&titlebar_view,
LogicalPosition::new(0.0, 0.0),
PhysicalSize::new(win_size.width, titlebar_height),
);
set_view_properties(
&ask_view,
LogicalPosition::new(
0.0,
(win_size.height as f64 / scale_factor) - ask_mode_height,
),
PhysicalSize::new(win_size.width, ask_height),
);
}
#[cfg(not(target_os = "macos"))]
{
set_view_properties(
&main_view,
LogicalPosition::new(0.0, 0.0),
PhysicalSize::new(
win_size.width,
win_size.height - (ask_height + titlebar_height),
),
);
set_view_properties(
&titlebar_view,
LogicalPosition::new(
0.0,
(win_size.height as f64 / scale_factor) - TITLEBAR_HEIGHT,
),
PhysicalSize::new(win_size.width, titlebar_height),
);
set_view_properties(
&ask_view,
LogicalPosition::new(
0.0,
(win_size.height as f64 / scale_factor) - ask_mode_height - TITLEBAR_HEIGHT,
),
PhysicalSize::new(win_size.width, ask_height),
);
}
}
================================================
FILE: src-tauri/src/core/conf.rs
================================================
use log::error;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
collections::BTreeMap,
fs::{self, File},
io::{Read, Write},
path::PathBuf,
};
use tauri::{AppHandle, Manager, Theme};
#[derive(Serialize, Deserialize, Debug)]
pub struct AppConf {
pub theme: String,
pub stay_on_top: bool,
pub ask_mode: bool,
pub mac_titlebar_hidden: bool,
}
impl AppConf {
pub fn new() -> Self {
Self {
theme: "system".to_string(),
stay_on_top: false,
ask_mode: false,
#[cfg(target_os = "macos")]
mac_titlebar_hidden: true,
#[cfg(not(target_os = "macos"))]
mac_titlebar_hidden: false,
}
}
pub fn get_conf_path(app: &AppHandle) -> Result> {
let config_dir = app
.path()
.config_dir()?
.join("com.nofwl.chatgpt")
.join("config.json");
Ok(config_dir)
}
pub fn get_scripts_path(app: &AppHandle) -> Result> {
let scripts_dir = app
.path()
.config_dir()?
.join("com.nofwl.chatgpt")
.join("scripts");
Ok(scripts_dir)
}
pub fn load_script(app: &AppHandle, filename: &str) -> String {
let script_file = Self::get_scripts_path(app).unwrap().join(filename);
fs::read_to_string(script_file).unwrap_or_else(|_| "".to_string())
}
pub fn load(app: &AppHandle) -> Result> {
let path = Self::get_conf_path(app)?;
if !path.exists() {
let config = Self::new();
config.save(app)?;
return Ok(config);
}
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let config: Result = serde_json::from_str(&contents);
// Handle conditional fields and fallback to defaults if necessary
if let Err(e) = &config {
error!("[conf::load] {}", e);
let mut default_config = Self::new();
default_config = default_config.amend(serde_json::from_str(&contents)?)?;
default_config.save(app)?;
return Ok(default_config);
}
Ok(config?)
}
pub fn save(&self, app: &AppHandle) -> Result<(), Box> {
let path = Self::get_conf_path(app)?;
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
let mut file = File::create(path)?;
let contents = serde_json::to_string_pretty(self)?;
// dbg!(&contents);
file.write_all(contents.as_bytes())?;
Ok(())
}
pub fn amend(self, json: Value) -> Result {
let val = serde_json::to_value(self)?;
let mut config: BTreeMap = serde_json::from_value(val)?;
let new_json: BTreeMap = serde_json::from_value(json)?;
for (k, v) in new_json {
config.insert(k, v);
}
let config_str = serde_json::to_string_pretty(&config)?;
serde_json::from_str::(&config_str).map_err(|err| {
error!("[conf::amend] {}", err);
err
})
}
pub fn get_theme(app: &AppHandle) -> Theme {
let theme = Self::load(app).unwrap().theme;
match theme.as_str() {
"system" => match dark_light::detect() {
dark_light::Mode::Dark => Theme::Dark,
dark_light::Mode::Light => Theme::Light,
dark_light::Mode::Default => Theme::Light,
},
"dark" => Theme::Dark,
_ => Theme::Light,
}
}
}
================================================
FILE: src-tauri/src/core/constant.rs
================================================
pub static TITLEBAR_HEIGHT: f64 = 28.0;
pub static ASK_HEIGHT: f64 = 120.0;
pub static WINDOW_SETTINGS: &str = "settings";
pub static INIT_SCRIPT: &str = r#"
window.addEventListener('DOMContentLoaded', function() {
function handleUrlChange() {
const url = window.location.href;
if (url !== 'about:blank') {
console.log('URL changed:', url);
window.__TAURI__.webviewWindow.WebviewWindow.getByLabel('titlebar').emit('navigation:change', { url });
}
}
function handleLinkClick(event) {
const target = event.target;
if (target.tagName === 'A' && target.target && target.target !== '_blank') {
target.target = '_blank';
}
}
document.addEventListener('click', handleLinkClick, true);
window.addEventListener('popstate', handleUrlChange);
window.addEventListener('pushState', handleUrlChange);
window.addEventListener('replaceState', handleUrlChange);
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function() {
originalPushState.apply(this, arguments);
console.log('pushState called');
handleUrlChange();
};
history.replaceState = function() {
originalReplaceState.apply(this, arguments);
console.log('replaceState called');
handleUrlChange();
};
handleUrlChange();
});
"#;
================================================
FILE: src-tauri/src/core/mod.rs
================================================
pub mod cmd;
pub mod conf;
pub mod constant;
pub mod setup;
pub mod template;
pub mod window;
================================================
FILE: src-tauri/src/core/setup.rs
================================================
use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use tauri::{
webview::DownloadEvent, App, LogicalPosition, Manager, PhysicalSize, WebviewBuilder,
WebviewUrl, WindowBuilder, WindowEvent,
};
use tauri_plugin_shell::ShellExt;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use crate::core::{
conf::AppConf,
constant::{ASK_HEIGHT, INIT_SCRIPT, TITLEBAR_HEIGHT},
template,
};
pub fn init(app: &mut App) -> Result<(), Box> {
let handle = app.handle();
let conf = &AppConf::load(handle)?;
let ask_mode_height = if conf.ask_mode { ASK_HEIGHT } else { 0.0 };
template::Template::new(AppConf::get_scripts_path(handle)?);
tauri::async_runtime::spawn({
let handle = handle.clone();
async move {
let mut core_window = WindowBuilder::new(&handle, "core").title("ChatGPT");
#[cfg(target_os = "macos")]
{
core_window = core_window
.title_bar_style(TitleBarStyle::Overlay)
.hidden_title(true);
}
core_window = core_window
.resizable(true)
.inner_size(800.0, 600.0)
.min_inner_size(300.0, 200.0)
.theme(Some(AppConf::get_theme(&handle)));
let core_window = core_window
.build()
.expect("[core:window] Failed to build window");
let win_size = core_window
.inner_size()
.expect("[core:window] Failed to get window size");
// Wrap the window in Arc> to manage ownership across threads
let window = Arc::new(Mutex::new(core_window));
let main_view =
WebviewBuilder::new("main", WebviewUrl::App("https://chatgpt.com".into()))
.auto_resize()
.on_download({
let app_handle = handle.clone();
let download_path = Arc::new(Mutex::new(PathBuf::new()));
move |_, event| {
match event {
DownloadEvent::Requested { destination, .. } => {
let download_dir = app_handle
.path()
.download_dir()
.expect("[view:download] Failed to get download directory");
let mut locked_path = download_path
.lock()
.expect("[view:download] Failed to lock download path");
*locked_path = download_dir.join(&destination);
*destination = locked_path.clone();
}
DownloadEvent::Finished { success, .. } => {
let final_path = download_path
.lock()
.expect("[view:download] Failed to lock download path")
.clone();
if success {
app_handle
.shell()
.open(final_path.to_string_lossy(), None)
.expect("[view:download] Failed to open file");
}
}
_ => (),
}
true
}
})
.initialization_script(&AppConf::load_script(&handle, "ask.js"))
.initialization_script(INIT_SCRIPT);
let titlebar_view = WebviewBuilder::new(
"titlebar",
WebviewUrl::App("index.html".into()),
)
.auto_resize();
let ask_view =
WebviewBuilder::new("ask", WebviewUrl::App("index.html".into()))
.auto_resize();
let win = window.lock().unwrap();
let scale_factor = win.scale_factor().unwrap();
let titlebar_height = (scale_factor * TITLEBAR_HEIGHT).round() as u32;
let ask_height = (scale_factor * ask_mode_height).round() as u32;
#[cfg(target_os = "macos")]
{
let main_area_height = win_size.height - titlebar_height;
win.add_child(
titlebar_view,
LogicalPosition::new(0, 0),
PhysicalSize::new(win_size.width, titlebar_height),
)
.unwrap();
win.add_child(
ask_view,
LogicalPosition::new(
0.0,
(win_size.height as f64 / scale_factor) - ask_mode_height,
),
PhysicalSize::new(win_size.width, ask_height),
)
.unwrap();
win.add_child(
main_view,
LogicalPosition::new(0.0, TITLEBAR_HEIGHT),
PhysicalSize::new(win_size.width, main_area_height - ask_height),
)
.unwrap();
}
#[cfg(not(target_os = "macos"))]
{
win.add_child(
ask_view,
LogicalPosition::new(
0.0,
(win_size.height as f64 / scale_factor) - ask_mode_height,
),
PhysicalSize::new(win_size.width, ask_height),
)
.unwrap();
win.add_child(
titlebar_view,
LogicalPosition::new(
0.0,
(win_size.height as f64 / scale_factor) - ask_mode_height - TITLEBAR_HEIGHT,
),
PhysicalSize::new(win_size.width, titlebar_height),
)
.unwrap();
win.add_child(
main_view,
LogicalPosition::new(0.0, 0.0),
PhysicalSize::new(
win_size.width,
win_size.height - (ask_height + titlebar_height),
),
)
.unwrap();
}
let window_clone = Arc::clone(&window);
let set_view_properties =
|view: &tauri::Webview, position: LogicalPosition, size: PhysicalSize| {
if let Err(e) = view.set_position(position) {
eprintln!("[view:position] Failed to set view position: {}", e);
}
if let Err(e) = view.set_size(size) {
eprintln!("[view:size] Failed to set view size: {}", e);
}
};
win.on_window_event(move |event| {
let conf = &AppConf::load(&handle).unwrap();
let ask_mode_height = if conf.ask_mode { ASK_HEIGHT } else { 0.0 };
let ask_height = (scale_factor * ask_mode_height).round() as u32;
if let WindowEvent::Resized(size) = event {
let win = window_clone.lock().unwrap();
let main_view = win
.get_webview("main")
.expect("[view:main] Failed to get webview window");
let titlebar_view = win
.get_webview("titlebar")
.expect("[view:titlebar] Failed to get webview window");
let ask_view = win
.get_webview("ask")
.expect("[view:ask] Failed to get webview window");
#[cfg(target_os = "macos")]
{
set_view_properties(
&main_view,
LogicalPosition::new(0.0, TITLEBAR_HEIGHT),
PhysicalSize::new(
size.width,
size.height - (titlebar_height + ask_height),
),
);
set_view_properties(
&titlebar_view,
LogicalPosition::new(0.0, 0.0),
PhysicalSize::new(size.width, titlebar_height),
);
set_view_properties(
&ask_view,
LogicalPosition::new(
0.0,
(size.height as f64 / scale_factor) - ask_mode_height,
),
PhysicalSize::new(size.width, ask_height),
);
}
#[cfg(not(target_os = "macos"))]
{
set_view_properties(
&main_view,
LogicalPosition::new(0.0, 0.0),
PhysicalSize::new(
size.width,
size.height - (ask_height + titlebar_height),
),
);
set_view_properties(
&titlebar_view,
LogicalPosition::new(
0.0,
(size.height as f64 / scale_factor) - TITLEBAR_HEIGHT,
),
PhysicalSize::new(size.width, titlebar_height),
);
set_view_properties(
&ask_view,
LogicalPosition::new(
0.0,
(size.height as f64 / scale_factor)
- ask_mode_height
- TITLEBAR_HEIGHT,
),
PhysicalSize::new(size.width, ask_height),
);
}
}
});
}
});
Ok(())
}
================================================
FILE: src-tauri/src/core/template.rs
================================================
use anyhow::{Context, Result};
use log::{error, info};
use regex::Regex;
use semver::Version;
use serde_json::json;
use std::{
fs::{self, File},
io::{Read, Write},
path::Path,
};
pub static SCRIPT_ASK: &[u8] = include_bytes!("../../scripts/ask.js");
/// Struct representing the template with the script data.
#[derive(Debug)]
pub struct Template {
pub ask: Vec,
}
impl Template {
/// Creates a new Template instance, initializing it with the script data.
pub fn new>(template_dir: P) -> Self {
let template_dir = template_dir.as_ref();
let mut template = Template::default();
let files = vec![(template_dir.join("ask.js"), &mut template.ask)];
for (filename, _) in files {
match update_or_create_file(&filename, SCRIPT_ASK) {
Ok(updated) => {
if updated {
info!("Script updated or created: {}", filename.display());
} else {
info!("Script is up-to-date: {}", filename.display());
}
}
Err(e) => {
error!("Failed to process script, {}: {}", filename.display(), e);
}
}
}
template
}
}
impl Default for Template {
fn default() -> Template {
Template {
ask: Vec::from(SCRIPT_ASK),
}
}
}
/// Reads the version information from the given data.
fn read_version_info(data: &[u8]) -> Result {
let content = String::from_utf8_lossy(data);
let re_name = Regex::new(r"@name\s+(.*?)\n").context("Failed to compile name regex")?;
let re_version =
Regex::new(r"@version\s+(.*?)\n").context("Failed to compile version regex")?;
let re_url = Regex::new(r"@url\s+(.*?)\n").context("Failed to compile url regex")?;
let name = re_name
.captures(&content)
.and_then(|cap| cap.get(1))
.map_or(String::new(), |m| m.as_str().trim().to_string());
let version = re_version
.captures(&content)
.and_then(|cap| cap.get(1))
.map_or(String::new(), |m| m.as_str().trim().to_string());
let url = re_url
.captures(&content)
.and_then(|cap| cap.get(1))
.map_or(String::new(), |m| m.as_str().trim().to_string());
let json_data = json!({
"name": name,
"version": version,
"url": url,
});
Ok(json_data)
}
/// Reads the contents of the given file.
fn read_file_contents>(filename: P) -> Result> {
let filename = filename.as_ref();
let mut file = File::open(filename)?;
let mut contents = Vec::new();
file.read_to_end(&mut contents)?;
Ok(contents)
}
/// Writes the given data to the specified file.
fn write_file_contents>(filename: P, data: &[u8]) -> Result<()> {
let filename = filename.as_ref();
let mut file = File::create(filename)?;
file.write_all(data)?;
Ok(())
}
/// Creates the necessary directories for the specified file path.
fn create_dir>(filename: P) -> Result<()> {
let filename = filename.as_ref();
if let Some(parent) = filename.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
Ok(())
}
/// Updates the file if the new data has a newer version or if version info is missing,
/// or creates the file if it doesn't exist.
fn update_or_create_file>(filename: P, new_data: &[u8]) -> Result {
let filename = filename.as_ref();
// Ensure directory exists
create_dir(filename)?;
let current_data = read_file_contents(filename);
match current_data {
Ok(current_data) => {
let new_info = read_version_info(new_data)?;
let current_info = read_version_info(¤t_data);
match (
new_info.get("version").and_then(|v| v.as_str()),
current_info,
) {
(Some(new_version), Ok(current_info)) => {
let current_version = current_info
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("");
if current_version.is_empty()
|| Version::parse(new_version)? > Version::parse(current_version)?
{
write_file_contents(filename, new_data)?;
info!("{} → {}", current_version, new_version);
Ok(true)
} else {
Ok(false)
}
}
// If there is an error reading current version info, update the file
(Some(_), Err(_)) => {
write_file_contents(filename, new_data)?;
Ok(true)
}
(None, _) => {
// If there is an error reading new version info, don't update the file
Ok(false)
}
}
}
Err(_) => {
// If there is an error reading the current file, create a new file
write_file_contents(filename, new_data)?;
Ok(true)
}
}
}
================================================
FILE: src-tauri/src/core/window.rs
================================================
use tauri::{command, AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use crate::core::constant::WINDOW_SETTINGS;
#[command]
pub fn open_settings(app: AppHandle) {
match app.get_webview_window(WINDOW_SETTINGS) {
Some(window) => {
window.show().unwrap();
}
None => {
WebviewWindowBuilder::new(&app, WINDOW_SETTINGS, WebviewUrl::App("index.html".into()))
.build()
.unwrap();
}
}
}
================================================
FILE: src-tauri/src/main.rs
================================================
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod core;
use core::{cmd, setup, window};
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
cmd::view_reload,
cmd::view_url,
cmd::view_go_forward,
cmd::view_go_back,
cmd::set_view_ask,
cmd::get_app_conf,
cmd::window_pin,
cmd::ask_sync,
cmd::ask_send,
cmd::set_theme,
window::open_settings,
])
.setup(setup::init)
.run(tauri::generate_context!())
.expect("error while running lencx/ChatGPT application");
}
================================================
FILE: src-tauri/tauri.conf.json
================================================
{
"productName": "ChatGPT",
"version": "../package.json",
"identifier": "com.nofwl.chatgpt",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
================================================
FILE: tailwind.config.js
================================================
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{js,jsx,ts,tsx}',
],
theme: {
screens: {
tablet: '480px',
},
extend: {
colors: {
'app-gray-1': '#171717',
'app-gray-2': '#212121',
'app-gray-3': '#2f2f2f;',
'app-active': '#10a37f',
}
},
},
plugins: [
require('@tailwindcss/typography'),
require('autoprefixer'),
],
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"~/*": ["src/*"],
"~components/*": ["src/components/*"],
"~view/*": ["src/view/*"],
"~hooks/*": ["src/hooks/*"],
"~utils/*": ["src/utils/*"],
"~icons/*": ["src/icons/*"],
"~layout/*": ["src/layout/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
================================================
FILE: tsconfig.node.json
================================================
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
================================================
FILE: vite.config.ts
================================================
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [tsconfigPaths(), react()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
watch: {
// 3. tell vite to ignore watching `src-tauri`
ignored: ['**/src-tauri/**'],
},
},
}));