Compare commits

..

22 Commits

Author SHA1 Message Date
N0\A
aa2c23537c wayland removed 2025-11-07 09:31:14 +01:00
N0\A
69b0fb3226 . 2025-11-07 09:30:27 +01:00
N0\A
1814054cc2 supercopy update 2025-11-07 09:24:13 +01:00
N0\A
3b39ed1806 wayland try 1 2025-11-06 15:11:57 +01:00
N0\A
754acac08a quit fix + don't show menu when hidden 2025-11-06 11:03:21 +01:00
N0\A
e0ade52eb5 Merge branch 'main' of https://git.krzak.org/N0VA/CLARA 2025-11-02 18:16:13 +01:00
N0\A
0b115bfe27 better gitignore 2025-11-02 18:16:07 +01:00
N0\A
9fa55dc02e should work without js 2025-11-02 14:27:29 +01:00
N0\A
2c42c88555 config window 2025-10-31 17:16:52 +01:00
N0\A
795cdb9daf updated readme 2025-10-31 17:10:53 +01:00
N0\A
b6becb6912 removed --no-super 2025-10-31 17:04:49 +01:00
N0\A
a6a83bf70e config 2025-10-31 17:01:02 +01:00
N0\A
790c9c3778 separate share html 2025-10-31 16:25:56 +01:00
N0\A
75cc6241aa fix the edges getting cut off 2025-10-30 17:17:16 +01:00
N0\A
8c42dfffbd mobile-friendly http share 2025-10-30 17:14:26 +01:00
N0\A
620f6db259 readme update 2025-10-30 09:22:28 +01:00
N0\A
aa3741e357 Keywords and better commands search 2025-10-30 09:16:26 +01:00
N0\A
02755e7d63 updater timeout 2025-10-30 09:16:07 +01:00
N0\A
5b67d6599b windows app launcher fix 2025-10-29 21:19:29 +01:00
N0\A
87e0b30ce0 dukto fix try 2025-10-29 13:23:38 +01:00
N0\A
301a0eb264 menu and app launcher fix 2025-10-29 12:33:36 +01:00
N0\A
13b7892f31 experimental windows support 2025-10-29 12:04:43 +01:00
18 changed files with 1002 additions and 331 deletions

3
.gitignore vendored
View File

@@ -9,5 +9,6 @@ __pycache__/
*.pyc.* *.pyc.*
*.pyo.* *.pyo.*
# SUPERCOPY SUPERCOPY.py
copy.md copy.md
.vscode

View File

@@ -1,7 +1,7 @@
# CLARA # CLARA
### Computer Linguistically Advanced Reactive Assistant ### Computer Linguistically Advanced Reactive Assistant
A ***very*** WIP desktop assistant for X11-based desktops. A WIP desktop assistant for Linux and Windows.
![CLARA](/assets/2ktan.png) ![CLARA](/assets/2ktan.png)
@@ -11,8 +11,8 @@ A ***very*** WIP desktop assistant for X11-based desktops.
- App launcher - App launcher
- File search - File search
- Web search - Web search
- Updater (git) - Updater
- Super key menu - Global menu (defaults: `Super` on Linux, `Ctrl+Space` on Windows) (you can disable it in the config)
- Local network file and text transfer (Dukto protocol) - Local network file and text transfer (Dukto protocol)
- Browser based file and text transfer (HTTP) - Browser based file and text transfer (HTTP)
- Discord Rich Presence integration - Discord Rich Presence integration
@@ -20,18 +20,19 @@ A ***very*** WIP desktop assistant for X11-based desktops.
## Requirements ## Requirements
- Python 3 - Python 3
- X11 Desktop - X11 Desktop (Linux only)
- `fd` - [`fd`](https://github.com/sharkdp/fd)
- `git` - `git`
## Instructions ## Instructions
1. Clone this repository 1. Clone this repository
2. Install all of the modules from `requirements.txt`, either with `pip install -r requirements.txt` or via your distribution's package manager. 2. Install all of the modules from `requirements.txt`, either with `pip install -r requirements.txt` or via your distribution's package manager.
3. You will also need to install the `fd` command-line tool for the file search feature to work. (e.g., `sudo apt install fd-find` on Debian/Ubuntu, `sudo pacman -S fd` on Arch Linux). 3. You will also need to install the `fd` command-line tool for the file search feature to work. (e.g., `sudo apt install fd-find` on Debian/Ubuntu, `sudo pacman -S fd` on Arch Linux).
> For other Linux distros and Windows go [here](https://github.com/sharkdp/fd)
4. Launch `main.py`. Available options: 4. Launch `main.py`. Available options:
- `--restart` Add a restart option to the right click menu - `--restart` Add a restart option to the right click menu
- `--no-quit` Hide the quit option - `--no-quit` Hide the quit option
- `--no-super` Disable the super key menu
- `--no-update` Don't update automatically on startup - `--no-update` Don't update automatically on startup
5. To configure, go to `~/.config/CLARA/config.json` on Linux and `~/AppData/Roaming/CLARA/config.json` on Windows.
If you want to contribute in any way, PRs (and issues) welcome If you want to contribute in any way, PRs (and issues) welcome

View File

@@ -1,36 +1,257 @@
files = [ import os
"core/app_launcher.py", import sys
"core/discord_presence.py", import fnmatch
"core/dukto.py",
"core/file_search.py",
"core/headers.py",
"core/http_share.py",
"core/updater.py",
"core/web_search.py",
"strings/en.json",
"strings/personality_en.json",
"windows/app_launcher.py",
"windows/calculator.py",
"windows/file_search.py",
"windows/main_window.py",
"windows/text_viewer.py",
"windows/web_results.py",
"main.py",
"README.md",
"requirements.txt"
]
codeblock = "```" def get_language(file_path):
"""Detect programming language based on file extension."""
extension_map = {
'.html': 'html', '.htm': 'html', '.css': 'css', '.js': 'javascript',
'.mjs': 'javascript', '.cjs': 'javascript', '.py': 'python',
'.pyc': 'python', '.pyo': 'python', '.md': 'markdown',
'.markdown': 'markdown', '.txt': 'text', '.json': 'json',
'.geojson': 'json', '.xml': 'xml', '.php': 'php', '.phtml': 'php',
'.sql': 'sql', '.sh': 'bash', '.bash': 'bash', '.zsh': 'bash',
'.fish': 'fish', '.yml': 'yaml', '.yaml': 'yaml', '.toml': 'toml',
'.ini': 'ini', '.cfg': 'ini', '.conf': 'ini', '.config': 'ini',
'.log': 'text', '.bat': 'batch', '.cmd': 'batch', '.ps1': 'powershell',
'.psm1': 'powershell', '.psd1': 'powershell', '.rb': 'ruby',
'.gemspec': 'ruby', '.go': 'go', '.java': 'java', '.class': 'java',
'.c': 'c', '.h': 'cpp', '.cpp': 'cpp', '.cc': 'cpp', '.cxx': 'cpp',
'.c++': 'cpp', '.hpp': 'cpp', '.hh': 'cpp', '.hxx': 'cpp',
'.cs': 'csharp', '.csx': 'csharp', '.swift': 'swift', '.kt': 'kotlin',
'.kts': 'kotlin', '.rs': 'rust', '.ts': 'typescript', '.tsx': 'typescript',
'.mts': 'typescript', '.cts': 'typescript', '.jsx': 'javascript',
'.vue': 'vue', '.scss': 'scss', '.sass': 'sass', '.less': 'less',
'.styl': 'stylus', '.stylus': 'stylus', '.graphql': 'graphql',
'.gql': 'graphql', '.dockerfile': 'dockerfile', '.dockerignore': 'dockerignore',
'.editorconfig': 'ini', '.gitignore': 'gitignore', '.gitattributes': 'gitattributes',
'.gitmodules': 'gitmodules', '.prettierrc': 'json', '.eslintrc': 'json',
'.babelrc': 'json', '.npmignore': 'gitignore', '.lock': 'text',
'.env': 'env', '.env.local': 'env', '.env.development': 'env',
'.env.production': 'env', '.env.test': 'env',
}
copy = "" ext = os.path.splitext(file_path)[1].lower()
return extension_map.get(ext, '')
for file in files: def should_exclude(file_path, root_dir):
with open(file, "r", encoding="utf-8") as f: """Determine if a file should be excluded from copying."""
lines = f.readlines() abs_path = os.path.abspath(file_path)
copy += f"### {file}\n\n" rel_path = os.path.relpath(abs_path, root_dir)
copy += f"{codeblock}python\n" rel_path_forward = rel_path.replace(os.sep, '/')
copy += "".join(lines) basename = os.path.basename(file_path)
copy += f"\n{codeblock}\n\n"
with open("copy.md", "w", encoding="utf-8") as f: # Exclude specific files
f.write(copy) exclude_files = {'.pyc'}
if rel_path_forward in exclude_files or basename in exclude_files:
return True
# Exclude image files
image_extensions = {
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.bmp', '.ico',
'.tiff', '.tif', '.webp', '.heic', '.heif', '.avif',
'.jfif', '.pjpeg', '.pjp', '.tga', '.psd', '.raw',
'.cr2', '.nef', '.orf', '.sr2', '.arw', '.dng', '.rw2',
'.raf', '.3fr', '.kdc', '.mef', '.mrw', '.pef', '.srw',
'.x3f', '.r3d', '.fff', '.iiq', '.erf', '.nrw'
}
ext = os.path.splitext(file_path)[1].lower()
if ext in image_extensions:
return True
return False
def load_gitignore_patterns(root_dir):
"""Load patterns from .gitignore file."""
patterns = []
gitignore_path = os.path.join(root_dir, '.gitignore')
if os.path.isfile(gitignore_path):
try:
with open(gitignore_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if line and not line.startswith('#'):
# Remove trailing backslash for escaped #
if line.startswith(r'\#'):
line = line[1:]
patterns.append(line)
except Exception as e:
print(f"Warning: Could not read .gitignore: {e}")
return patterns
def is_ignored(path, patterns, root_dir):
"""Check if path matches any gitignore pattern (simplified)."""
if not patterns:
return False
# Get relative path with forward slashes
rel_path = os.path.relpath(path, root_dir).replace(os.sep, '/')
# For directories, also check with trailing slash
if os.path.isdir(path):
rel_path_with_slash = rel_path + '/'
else:
rel_path_with_slash = rel_path
for pattern in patterns:
# Skip negation patterns (too complex for this script)
if pattern.startswith('!'):
continue
# Directory pattern (ending with /)
if pattern.endswith('/'):
if not os.path.isdir(path):
continue
pattern = pattern.rstrip('/')
# Match directory name or anything inside it
if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(rel_path_with_slash, pattern + '/*'):
return True
continue
# Absolute pattern (starting with /) - match from root only
if pattern.startswith('/'):
pattern = pattern.lstrip('/')
if fnmatch.fnmatch(rel_path, pattern):
return True
continue
# Pattern without slash - matches at any level
if '/' not in pattern:
# Check basename
basename = os.path.basename(rel_path)
if fnmatch.fnmatch(basename, pattern):
return True
else:
# Pattern with slash - relative path match
if fnmatch.fnmatch(rel_path, pattern):
return True
return False
def get_files_from_directory(directory, recursive=False, root_dir=None, gitignore_patterns=None):
"""Get all files from a directory, optionally recursively."""
if root_dir is None:
root_dir = os.getcwd()
if gitignore_patterns is None:
gitignore_patterns = []
files_list = []
abs_directory = os.path.abspath(directory)
if not os.path.exists(abs_directory):
print(f"Warning: Directory '{directory}' not found.")
return files_list
# Skip if directory itself is ignored
if is_ignored(abs_directory, gitignore_patterns, root_dir):
return files_list
if recursive:
for dirpath, dirnames, filenames in os.walk(abs_directory):
# Filter directories: exclude hidden and gitignored
dirnames[:] = [
d for d in dirnames
if not d.startswith('.') and not is_ignored(os.path.join(dirpath, d), gitignore_patterns, root_dir)
]
# Filter files
for filename in filenames:
if filename.startswith('.'):
continue
full_path = os.path.join(dirpath, filename)
if (os.path.isfile(full_path) and
not should_exclude(full_path, root_dir) and
not is_ignored(full_path, gitignore_patterns, root_dir)):
files_list.append(full_path)
else:
for filename in os.listdir(abs_directory):
if filename.startswith('.'):
continue
full_path = os.path.join(abs_directory, filename)
# Skip directories in non-recursive mode
if os.path.isdir(full_path):
continue
if (os.path.isfile(full_path) and
not should_exclude(full_path, root_dir) and
not is_ignored(full_path, gitignore_patterns, root_dir)):
files_list.append(full_path)
return files_list
def main():
"""Main execution function."""
root_dir = os.getcwd()
script_path = os.path.abspath(__file__)
output_file = "copy.md"
codeblock = "```"
# Load .gitignore patterns
gitignore_patterns = load_gitignore_patterns(root_dir)
if gitignore_patterns:
print(f"Loaded {len(gitignore_patterns)} patterns from .gitignore")
def is_output_file(path):
return os.path.abspath(path) == os.path.abspath(output_file)
# Directories to process: (path, recursive)
directories = [
("./", False), # Root directory
("assets/", True), # Archive directory (with subdirectories)
("core/", True), # Legacy directory
("strings/", True), # Maybe directory
("windows/", True)
]
all_files = []
for directory, recursive in directories:
files = get_files_from_directory(directory, recursive, root_dir, gitignore_patterns)
files = [f for f in files if not is_output_file(f) and os.path.abspath(f) != script_path]
all_files.extend(files)
# Remove duplicates and sort
all_files = sorted(set(all_files))
markdown_content = "# Main website\n\n"
file_count = 0
for file_path in all_files:
try:
rel_path = os.path.relpath(file_path, root_dir)
language = get_language(file_path)
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
markdown_content += f"### {rel_path.replace(os.sep, '/')}\n\n"
markdown_content += f"{codeblock}{language}\n" if language else f"{codeblock}\n"
markdown_content += content
markdown_content += f"\n{codeblock}\n\n"
file_count += 1
except UnicodeDecodeError:
print(f"Warning: Could not read {file_path} as text. Skipping.")
except Exception as e:
print(f"Error processing {file_path}: {e}")
markdown_content += f"<!-- Processed {file_count} files -->\n"
try:
with open(output_file, "w", encoding="utf-8") as f:
f.write(markdown_content)
print(f"Successfully created {output_file} with {file_count} files.")
except Exception as e:
print(f"Error writing to {output_file}: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,24 +1,39 @@
from pathlib import Path from pathlib import Path
import os import os
import configparser import configparser
from typing import Optional from typing import Optional, List
import platform
import subprocess
import shlex
if platform.system() == "Windows":
try:
from win32com.client import Dispatch # type: ignore
import win32api # type: ignore
import win32con # type: ignore
except ImportError:
print("Windows specific functionality requires 'pywin32'. Please run 'pip install pywin32'.")
Dispatch = None
win32api = None
win32con = None
_app_cache: Optional[list['App']] = None _app_cache: Optional[list['App']] = None
class App: class App:
def __init__(self, name: str, exec: str, icon: str = "", hidden: bool = False, generic_name: str = "", comment: str = "", command: str = ""): def __init__(self, name: str, exec: str, icon: str = "", hidden: bool = False, generic_name: str = "", comment: str = "", command: str = "", keywords: Optional[List[str]] = None):
self.name = name self.name = name
self.exec = exec self.exec = exec
self.icon = icon self.icon = icon
self.hidden = hidden self.hidden = hidden
self.generic_name = generic_name self.generic_name = generic_name
self.comment = comment self.comment = comment
self.command = command self.command = command if command else os.path.basename(exec.split(' ')[0])
self.keywords = keywords if keywords is not None else []
def __str__(self): def __str__(self):
return f"App(name={self.name}, exec={self.exec}, command={self.command}, icon={self.icon}, hidden={self.hidden}, generic_name={self.generic_name}, comment={self.comment})" return f"App(name={self.name}, exec={self.exec}, command={self.command}, icon={self.icon}, hidden={self.hidden}, generic_name={self.generic_name}, comment={self.comment}, keywords={self.keywords})"
def get_desktop_dirs(): def get_desktop_dirs_linux():
dirs = [ dirs = [
Path.home() / ".local/share/applications", Path.home() / ".local/share/applications",
Path.home() / ".var/lib/app/flatpak/exports/share/applications", Path.home() / ".var/lib/app/flatpak/exports/share/applications",
@@ -34,6 +49,16 @@ def get_desktop_dirs():
return [d for d in dirs if d.exists()] return [d for d in dirs if d.exists()]
def get_start_menu_dirs_windows():
appdata = os.getenv('APPDATA')
programdata = os.getenv('PROGRAMDATA')
dirs = []
if appdata:
dirs.append(Path(appdata) / "Microsoft/Windows/Start Menu/Programs")
if programdata:
dirs.append(Path(programdata) / "Microsoft/Windows/Start Menu/Programs")
return [d for d in dirs if d.exists()]
def parse_desktop_file(file_path: Path) -> list[App]: def parse_desktop_file(file_path: Path) -> list[App]:
apps = [] apps = []
config = configparser.ConfigParser(interpolation=None) config = configparser.ConfigParser(interpolation=None)
@@ -54,8 +79,10 @@ def parse_desktop_file(file_path: Path) -> list[App]:
if main_name and not is_hidden: if main_name and not is_hidden:
main_exec = main_entry.get('Exec') main_exec = main_entry.get('Exec')
keywords_str = main_entry.get('Keywords', '')
keywords = [k.strip() for k in keywords_str.split(';') if k.strip()]
if main_exec: if main_exec:
command_name = os.path.basename(main_exec.split(' ')[0])
apps.append(App( apps.append(App(
name=main_name, name=main_name,
exec=main_exec, exec=main_exec,
@@ -63,7 +90,7 @@ def parse_desktop_file(file_path: Path) -> list[App]:
hidden=False, hidden=False,
generic_name=main_entry.get('GenericName', ''), generic_name=main_entry.get('GenericName', ''),
comment=main_entry.get('Comment', ''), comment=main_entry.get('Comment', ''),
command=command_name keywords=keywords
)) ))
if 'Actions' in main_entry: if 'Actions' in main_entry:
@@ -76,37 +103,52 @@ def parse_desktop_file(file_path: Path) -> list[App]:
action_exec = action_section.get('Exec') action_exec = action_section.get('Exec')
if action_name and action_exec: if action_name and action_exec:
action_command_name = os.path.basename(action_exec.split(' ')[0])
combined_name = f"{main_name} - {action_name}" combined_name = f"{main_name} - {action_name}"
apps.append(App( apps.append(App(
name=combined_name, name=combined_name,
exec=action_exec, exec=action_exec,
icon=main_entry.get('Icon', ''), icon=main_entry.get('Icon', ''),
hidden=False, keywords=keywords
command=action_command_name
)) ))
return apps return apps
def parse_lnk_file(file_path: Path) -> Optional[App]:
if not Dispatch:
return None
try:
shell = Dispatch("WScript.Shell")
shortcut = shell.CreateShortCut(str(file_path))
target = shortcut.TargetPath
arguments = shortcut.Arguments
if not target or not os.path.exists(target):
return None
full_exec = f'"{target}"'
if arguments:
full_exec += f' {arguments}'
return App(
name=file_path.stem,
exec=full_exec,
comment=shortcut.Description,
icon=shortcut.IconLocation.split(',')[0] if shortcut.IconLocation else ""
)
except Exception:
return None
def is_user_dir(path: Path) -> bool: def is_user_dir(path: Path) -> bool:
path_str = str(path) path_str = str(path)
user_home = str(Path.home()) user_home = str(Path.home())
return path_str.startswith(user_home) return path_str.startswith(user_home)
def list_apps(force_reload: bool = False) -> list[App]: def list_apps_linux() -> List[App]:
global _app_cache
if _app_cache is not None and not force_reload:
return _app_cache
apps_dict = {} apps_dict = {}
for desktop_dir in get_desktop_dirs_linux():
for desktop_dir in get_desktop_dirs():
is_user = is_user_dir(desktop_dir) is_user = is_user_dir(desktop_dir)
for file_path in desktop_dir.glob("*.desktop"): for file_path in desktop_dir.glob("*.desktop"):
for app in parse_desktop_file(file_path): for app in parse_desktop_file(file_path):
if app.hidden or not app.name or not app.exec: if app.hidden or not app.name or not app.exec:
continue continue
@@ -116,23 +158,62 @@ def list_apps(force_reload: bool = False) -> list[App]:
apps_dict[app.name] = (app, is_user) apps_dict[app.name] = (app, is_user)
else: else:
apps_dict[app.name] = (app, is_user) apps_dict[app.name] = (app, is_user)
return [app for app, _ in apps_dict.values()]
def list_apps_windows() -> List[App]:
apps_dict = {}
for start_menu_dir in get_start_menu_dirs_windows():
for file_path in start_menu_dir.rglob("*.lnk"):
app = parse_lnk_file(file_path)
if app and app.exec and app.name:
if app.exec not in apps_dict or len(app.name) > len(apps_dict[app.exec].name):
apps_dict[app.exec] = app
return list(apps_dict.values())
def list_apps(force_reload: bool = False) -> list[App]:
global _app_cache
if _app_cache is not None and not force_reload:
return _app_cache
if platform.system() == "Windows":
_app_cache = list_apps_windows()
else:
_app_cache = list_apps_linux()
_app_cache = [app for app, _ in apps_dict.values()]
return _app_cache return _app_cache
def reload_app_cache() -> list[App]: def reload_app_cache() -> list[App]:
return list_apps(force_reload=True) return list_apps(force_reload=True)
def launch(app: App): def launch(app: App):
import subprocess if platform.system() == "Windows":
import shlex if not win32api or not win32con:
print(f"Failed to launch '{app.name}': pywin32 components are missing.")
return
cleaned_exec = app.exec.split(' %')[0] try:
command_parts = shlex.split(app.exec, posix=False)
target = command_parts[0]
try: arguments = subprocess.list2cmdline(command_parts[1:])
subprocess.Popen(shlex.split(cleaned_exec))
except Exception as e: win32api.ShellExecute(
print(f"Failed to launch '{app.name}': {e}") 0, # Parent window handle (0 for desktop)
"open", # Operation
target, # File to execute or open
arguments, # Parameters
"", # Working directory (None for default)
win32con.SW_SHOWNORMAL # How to show the window
)
except Exception as e:
print(f"Failed to launch '{app.name}': {e}")
else:
cleaned_exec = app.exec.split(' %')[0]
try:
subprocess.Popen(shlex.split(cleaned_exec))
except Exception as e:
print(f"Failed to launch '{app.name}': {e}")
if __name__ == "__main__": if __name__ == "__main__":

82
core/config.py Normal file
View File

@@ -0,0 +1,82 @@
import json
from pathlib import Path
from typing import Any, Dict
import platform
class Config:
DEFAULT_CONFIG = {
"hotkey": "super",
"discord_presence": True,
"auto_update": True,
"http_share_port": 8080,
"dukto_udp_port": 4644,
"dukto_tcp_port": 4644,
"search_engine": "brave"
}
def __init__(self):
self.config_dir = self._get_config_dir()
self.config_file = self.config_dir / "config.json"
self.config_data = self._load_config()
def _get_config_dir(self) -> Path:
system = platform.system()
if system == "Windows":
base = Path.home() / "AppData" / "Roaming"
else:
base = Path.home() / ".config"
config_dir = base / "CLARA"
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir
def _load_config(self) -> Dict[str, Any]:
system = platform.system()
if self.config_file.exists():
try:
with open(self.config_file, 'r', encoding='utf-8') as f:
loaded = json.load(f)
# Merge with defaults for updates
config = self.DEFAULT_CONFIG.copy()
config.update(loaded)
return config
except (json.JSONDecodeError, IOError) as e:
print(f"Error loading config: {e}. Using defaults.")
return self.DEFAULT_CONFIG.copy()
else:
# default config file
conf = self.DEFAULT_CONFIG.copy()
if system == "Windows":
conf["hotkey"] = "ctrl+space"
else:
conf["hotkey"] = "super"
self._save_config(self.DEFAULT_CONFIG)
return self.DEFAULT_CONFIG.copy()
def _save_config(self, config_data: Dict[str, Any]):
try:
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(config_data, f, indent=4)
except IOError as e:
print(f"Error saving config: {e}")
def get(self, key: str, default: Any = None) -> Any:
return self.config_data.get(key, default)
def set(self, key: str, value: Any):
self.config_data[key] = value
self._save_config(self.config_data)
def get_all(self) -> Dict[str, Any]:
return self.config_data.copy()
def reset(self):
self.config_data = self.DEFAULT_CONFIG.copy()
self._save_config(self.config_data)
# Global config instance
config = Config()

View File

@@ -62,7 +62,8 @@ class DuktoProtocol:
name = getpass.getuser() + "'s CLARA" name = getpass.getuser() + "'s CLARA"
hostname = socket.gethostname() hostname = socket.gethostname()
system = platform.system() system = platform.system()
return f"{name} at {hostname} ({system})" pid = os.getpid()
return f"{name} at {hostname} ({system}) [PID:{pid}]"
def initialize(self): def initialize(self):
# UDP Socket for peer discovery # UDP Socket for peer discovery

View File

@@ -1,8 +1,25 @@
import shutil, subprocess, os import shutil
import subprocess
import os
import platform
import fnmatch
def _find_native(pattern: str, root: str):
"""Native Python implementation of file search using os.walk."""
results = []
for dirpath, _, filenames in os.walk(root):
for filename in fnmatch.filter(filenames, pattern):
results.append(os.path.join(dirpath, filename))
return results
def find(pattern: str, root: str='/'): def find(pattern: str, root: str='/'):
path = os.path.expanduser(root) path = os.path.expanduser(root)
if shutil.which('fd') is None: if shutil.which('fd') is None:
raise RuntimeError("fd not installed") return _find_native(f"*{pattern}*", path)
out = subprocess.check_output(['fd', pattern, path], text=True, errors='ignore') else:
return out.splitlines() try:
out = subprocess.check_output(['fd', pattern, path], text=True, errors='ignore')
return out.splitlines()
except subprocess.CalledProcessError:
return []

View File

@@ -25,215 +25,80 @@ class FileShareHandler(BaseHTTPRequestHandler):
shared_files: List[str] = [] shared_files: List[str] = []
shared_text: Optional[str] = None shared_text: Optional[str] = None
on_download: Optional[Callable[[str, str], None]] = None on_download: Optional[Callable[[str, str], None]] = None
html_template: Optional[str] = None
def log_message(self, format, *args): def log_message(self, format, *args):
pass pass
def _get_base_html(self, title: str, body_content: str, initial_data_script: str = "") -> str: @classmethod
return f"""<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> def load_html_template(cls):
<html xmlns="http://www.w3.org/1999/xhtml"> if cls.html_template is None:
<head> template_path = Path(__file__).parent / "share.html"
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> try:
<title>{html.escape(title)}</title> with open(template_path, 'r', encoding='utf-8') as f:
<style type="text/css"> cls.html_template = f.read()
html, body {{ margin:0; padding:0; }} except FileNotFoundError:
body {{ raise RuntimeError(f"HTML template not found at {template_path}")
font-family: Arial, Helvetica, sans-serif; return cls.html_template
background: #f3f4f6;
color: #222;
padding: 20px;
line-height: 1.4;
font-size: 14px;
}}
.container {{
width: 760px;
max-width: 98%;
margin: 0 auto;
background: #ffffff;
border: 1px solid #cfcfcf;
padding: 16px;
}}
.header {{
padding-bottom: 12px;
border-bottom: 2px solid #e6e6e6;
overflow: hidden;
}}
.brand {{
float: left;
font-weight: bold;
font-size: 20px;
color: #2b65a3;
}}
.subtitle {{
float: right;
color: #666;
font-size: 12px;
margin-top: 4px;
text-align: right;
}}
.main {{
margin-top: 16px;
overflow: hidden;
}}
.left-col {{
float: left;
width: 60%;
min-width: 300px;
}}
.right-col {{
float: left;
width: 36%;
min-width: 160px;
margin-left: 12px;
}}
.section {{ margin-bottom: 18px; }}
h2 {{
font-size: 16px;
margin: 6px 0 10px 0;
color: #333;
}}
p {{ margin: 8px 0; color: #444; }}
table.file-list {{
width: 100%;
border-collapse: collapse;
border-spacing: 0;
}}
table.file-list th, table.file-list td {{
padding: 8px 6px;
border-bottom: 1px solid #eaeaea;
text-align: left;
vertical-align: middle;
}}
table.file-list th {{
background: #f7f7f7;
font-size: 13px;
color: #333;
}}
a.button {{
display: inline-block;
padding: 6px 10px;
text-decoration: none;
border: 1px solid #9fb3d6;
background: #e9f0fb;
color: #1a4f86;
cursor: pointer;
font-size: 13px;
}}
a.button:hover {{ text-decoration: underline; }}
textarea.share-text {{
width: 98%;
height: 220px;
font-family: "Courier New", Courier, monospace;
font-size: 12px;
padding: 6px;
border: 1px solid #ccc;
background: #fafafa;
}}
.clearfix {{ display: block; }}
.footer {{
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #eaeaea;
text-align: center;
font-size: 11px;
color: #888;
}}
.footer a {{
color: #555;
text-decoration: none;
}}
.footer a:hover {{ text-decoration: underline; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="brand">CLARA Share</div>
<div class="subtitle">Simple file &amp; text sharing — local network</div>
</div>
{body_content}
<div class="footer">
<p>Powered by <a href="https://github.com/n0va-bot/CLARA" target="_blank">CLARA</a>, your friendly desktop assistant.</p>
</div>
</div>
{initial_data_script}
<script type="text/javascript">
(function() {{
var lastData = '';
function updateContent(data) {{ def _generate_shared_text_html(self, text: str) -> str:
var textContainer = document.getElementById('shared-text-container'); if not text:
var filesContainer = document.getElementById('shared-files-container'); return ""
var noContent = document.getElementById('no-content-message');
var hasText = data.text && data.text.length > 0; escaped_text = html.escape(text)
var hasFiles = data.files && data.files.length > 0; return f'''<h2>Shared Text</h2>
<p>Select the text below and copy it to your clipboard.</p>
<textarea class="share-text" readonly="readonly">{escaped_text}</textarea>'''
if (textContainer) {{ def _generate_shared_files_html(self, files: List[str]) -> str:
var textHtml = ''; """Generate HTML for shared files section."""
if (hasText) {{ if not files:
textHtml = '<h2>Shared Text</h2>' + return ""
'<p>Select the text below and copy it to your clipboard.</p>' +
'<textarea class="share-text" readonly="readonly">' + data.text + '</textarea>';
}}
textContainer.innerHTML = textHtml;
}}
if (filesContainer) {{ rows = ""
var filesHtml = ''; for i, filepath in enumerate(files):
if (hasFiles) {{ try:
var rows = ''; path = Path(filepath)
for (var i = 0; i < data.files.length; i++) {{ if path.exists() and path.is_file():
var file = data.files[i]; name = html.escape(path.name)
rows += '<tr>' + size = format_size(path.stat().st_size)
'<td>' + file.name + '</td>' + rows += f'''<tr>
'<td>' + file.size + '</td>' + <td>{name}</td>
'<td><a class="button" href="' + file.url + '">Download</a></td>' + <td>{size}</td>
'</tr>'; <td><a class="button" href="/download/{i}">Download</a></td>
}} </tr>'''
filesHtml = '<h2>Shared Files</h2>' + except Exception:
'<p>Click a button to download the corresponding file.</p>' + continue
'<table class="file-list" cellpadding="0" cellspacing="0">' +
'<tr><th>Filename</th><th>Size</th><th>Action</th></tr>' + rows + '</table>';
}}
filesContainer.innerHTML = filesHtml;
}}
if (noContent) {{ if not rows:
noContent.style.display = (hasText || hasFiles) ? 'none' : 'block'; return ""
}}
}}
function fetchData() {{ return f'''<h2>Shared Files</h2>
var xhr = new (window.XMLHttpRequest || ActiveXObject)('MSXML2.XMLHTTP.3.0'); <p>Click a button to download the corresponding file.</p>
xhr.open('GET', '/api/data', true); <table class="file-list" cellpadding="0" cellspacing="0">
xhr.onreadystatechange = function () {{ <tr><th>Filename</th><th>Size</th><th>Action</th></tr>
if (xhr.readyState === 4 && xhr.status === 200) {{ {rows}
if (xhr.responseText !== lastData) {{ </table>'''
lastData = xhr.responseText;
try {{
var data = JSON.parse(xhr.responseText);
updateContent(data);
}} catch (e) {{}}
}}
}}
}};
xhr.send(null);
}}
if (typeof initialDataJSON !== 'undefined' && initialDataJSON) {{ def _get_base_html(self, hostname: str, url: str, total_size_info: str,
lastData = initialDataJSON; no_content_display: str, shared_text_html: str, shared_files_html: str) -> str:
try {{ template = self.load_html_template()
var initialData = JSON.parse(initialDataJSON);
updateContent(initialData);
}} catch (e) {{}}
}}
setInterval(fetchData, 5000); replacements = {
}})(); '{{TITLE}}': 'CLARA Share',
</script> '{{HOSTNAME}}': html.escape(hostname),
</body> '{{URL}}': html.escape(url),
</html>""" '{{TOTAL_SIZE_INFO}}': total_size_info,
'{{NO_CONTENT_DISPLAY}}': no_content_display,
'{{SHARED_TEXT_HTML}}': shared_text_html,
'{{SHARED_FILES_HTML}}': shared_files_html
}
result = template
for placeholder, value in replacements.items():
result = result.replace(placeholder, value)
return result
def do_GET(self): def do_GET(self):
if self.path == '/': if self.path == '/':
@@ -279,35 +144,22 @@ class FileShareHandler(BaseHTTPRequestHandler):
except (FileNotFoundError, OSError): except (FileNotFoundError, OSError):
pass pass
if total_size_bytes > 0: if total_size_bytes > 0:
total_size_info = f'<p><strong>Total Size:</strong><br/><span>{format_size(total_size_bytes)}</span></p>' total_size_info = format_size(total_size_bytes)
main_content = f"""<div class="main clearfix"> url = f"http://{self._get_local_ip()}:{self.server.server_address[1]}/" #type: ignore
<div class="left-col"> no_content_display = 'none' if has_content else 'block'
<div class="section" id="shared-text-container"></div>
<div class="section" id="shared-files-container"></div>
</div>
<div class="right-col">
<div class="section">
<h2>Quick Info</h2>
<p><strong>Host:</strong><br/><span>{html.escape(hostname)}</span></p>
<p><strong>URL:</strong><br/><span>{html.escape(f"http://{self._get_local_ip()}:%s/")}</span></p>
{total_size_info}
<p><strong>Status:</strong><br/><span>Server running</span></p>
</div>
</div>
<div id="no-content-message" style="display: {'none' if has_content else 'block'};">
<p>No content is currently being shared.</p>
</div>
</div>""" % self.server.server_address[1] #type: ignore
initial_data_dict = self._get_api_data_dict() # Generate HTML server-side
json_string = json.dumps(initial_data_dict) shared_text_html = self._generate_shared_text_html(self.shared_text or "")
initial_data_script = f'<script type="text/javascript">var initialDataJSON = {json.dumps(json_string)};</script>' shared_files_html = self._generate_shared_files_html(self.shared_files)
html_content = self._get_base_html( html_content = self._get_base_html(
"CLARA Share", hostname=hostname,
main_content, url=url,
initial_data_script=initial_data_script total_size_info=total_size_info,
no_content_display=no_content_display,
shared_text_html=shared_text_html,
shared_files_html=shared_files_html
).encode('utf-8') ).encode('utf-8')
self.send_response(200) self.send_response(200)
@@ -435,6 +287,8 @@ class FileShareServer:
if f not in current_files: if f not in current_files:
self.shared_files.append(f) self.shared_files.append(f)
FileShareHandler.shared_files = self.shared_files
def share_text(self, text: str) -> str: def share_text(self, text: str) -> str:
self.shared_text = text self.shared_text = text
FileShareHandler.shared_text = self.shared_text FileShareHandler.shared_text = self.shared_text

243
core/share.html Normal file
View File

@@ -0,0 +1,243 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{TITLE}}</title>
<style type="text/css">
html {
box-sizing: border-box;
}
*, *:before, *:after {
-webkit-box-sizing: inherit;
-moz-box-sizing: inherit;
box-sizing: inherit;
}
html, body {
margin: 0;
padding: 0;
}
body {
font-family: Arial, Helvetica, sans-serif;
background: #f3f4f6;
color: #222;
line-height: 1.4;
font-size: 14px;
}
.container {
width: 95%;
max-width: 760px;
margin: 10px auto;
background: #ffffff;
border: 1px solid #cfcfcf;
padding: 16px;
}
.header {
padding-bottom: 12px;
border-bottom: 2px solid #e6e6e6;
overflow: hidden;
}
.brand {
float: left;
font-weight: bold;
font-size: 20px;
color: #2b65a3;
}
.subtitle {
float: right;
color: #666;
font-size: 12px;
margin-top: 4px;
text-align: right;
}
.main {
margin-top: 16px;
overflow: hidden;
}
.left-col {
float: left;
width: 60%;
min-width: 300px;
}
.right-col {
float: left;
width: 36%;
min-width: 160px;
margin-left: 12px;
}
.section { margin-bottom: 18px; }
h2 {
font-size: 16px;
margin: 6px 0 10px 0;
color: #333;
}
p { margin: 8px 0; color: #444; }
table.file-list {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
}
table.file-list th, table.file-list td {
padding: 8px 6px;
border-bottom: 1px solid #eaeaea;
text-align: left;
vertical-align: middle;
}
table.file-list th {
background: #f7f7f7;
font-size: 13px;
color: #333;
}
a.button {
display: inline-block;
padding: 6px 10px;
text-decoration: none;
border: 1px solid #9fb3d6;
background: #e9f0fb;
color: #1a4f86;
cursor: pointer;
font-size: 13px;
}
a.button:hover { text-decoration: underline; }
textarea.share-text {
width: 100%;
height: 220px;
font-family: "Courier New", Courier, monospace;
font-size: 12px;
padding: 6px;
border: 1px solid #ccc;
background: #fafafa;
}
.clearfix { display: block; }
.footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #eaeaea;
text-align: center;
font-size: 11px;
color: #888;
}
.footer a {
color: #555;
text-decoration: none;
}
.footer a:hover { text-decoration: underline; }
/* Responsive styles */
@media screen and (max-width: 600px) {
.left-col, .right-col {
width: 100%;
float: none;
margin-left: 0;
min-width: 0;
}
.subtitle {
float: none;
text-align: left;
margin-top: 8px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="brand">CLARA Share</div>
<div class="subtitle">Simple file &amp; text sharing — local network</div>
</div>
<div class="main clearfix">
<div class="left-col">
<div class="section" id="shared-text-container">
{{SHARED_TEXT_HTML}}
</div>
<div class="section" id="shared-files-container">
{{SHARED_FILES_HTML}}
</div>
</div>
<div class="right-col">
<div class="section">
<h2>Quick Info</h2>
<p><strong>Host:</strong><br/><span>{{HOSTNAME}}</span></p>
<p><strong>URL:</strong><br/><span>{{URL}}</span></p>
<p><strong>Total Size:</strong><br/><span>{{TOTAL_SIZE_INFO}}</span></p>
<p><strong>Status:</strong><br/><span>Server running</span></p>
</div>
</div>
<div id="no-content-message" style="display: {{NO_CONTENT_DISPLAY}};">
<p>No content is currently being shared.</p>
</div>
</div>
<div class="footer">
<p>Powered by <a href="https://github.com/n0va-bot/CLARA" target="_blank">CLARA</a>, your friendly desktop assistant.</p>
</div>
</div>
<script type="text/javascript">
(function() {
var lastData = '';
function updateContent(data) {
var textContainer = document.getElementById('shared-text-container');
var filesContainer = document.getElementById('shared-files-container');
var noContent = document.getElementById('no-content-message');
var hasText = data.text && data.text.length > 0;
var hasFiles = data.files && data.files.length > 0;
if (textContainer) {
var textHtml = '';
if (hasText) {
textHtml = '<h2>Shared Text</h2>' +
'<p>Select the text below and copy it to your clipboard.</p>' +
'<textarea class="share-text" readonly="readonly">' + data.text + '</textarea>';
}
textContainer.innerHTML = textHtml;
}
if (filesContainer) {
var filesHtml = '';
if (hasFiles) {
var rows = '';
for (var i = 0; i < data.files.length; i++) {
var file = data.files[i];
rows += '<tr>' +
'<td>' + file.name + '</td>' +
'<td>' + file.size + '</td>' +
'<td><a class="button" href="' + file.url + '">Download</a></td>' +
'</tr>';
}
filesHtml = '<h2>Shared Files</h2>' +
'<p>Click a button to download the corresponding file.</p>' +
'<table class="file-list" cellpadding="0" cellspacing="0">' +
'<tr><th>Filename</th><th>Size</th><th>Action</th></tr>' + rows + '</table>';
}
filesContainer.innerHTML = filesHtml;
}
if (noContent) {
noContent.style.display = (hasText || hasFiles) ? 'none' : 'block';
}
}
function fetchData() {
var xhr = new (window.XMLHttpRequest || ActiveXObject)('MSXML2.XMLHTTP.3.0');
xhr.open('GET', '/api/data', true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
if (xhr.responseText !== lastData) {
lastData = xhr.responseText;
try {
var data = JSON.parse(xhr.responseText);
updateContent(data);
} catch (e) {}
}
}
};
xhr.send(null);
}
// Refresh data every 5 seconds
setInterval(fetchData, 5000);
})();
</script>
</body>
</html>

View File

@@ -11,7 +11,8 @@ def is_update_available():
repo = Repo(REPO_DIR) repo = Repo(REPO_DIR)
origin = repo.remotes.origin origin = repo.remotes.origin
origin.fetch() repo.git.fetch(origin.name, kill_after_timeout=5)
local_commit = repo.head.commit local_commit = repo.head.commit
remote_commit = origin.refs[repo.active_branch.name].commit remote_commit = origin.refs[repo.active_branch.name].commit
@@ -29,7 +30,7 @@ def update_repository():
repo = Repo(REPO_DIR) repo = Repo(REPO_DIR)
origin = repo.remotes.origin origin = repo.remotes.origin
origin.pull() repo.git.pull(origin.name, kill_after_timeout=60)
return "UPDATED", "CLARA has been updated successfully." return "UPDATED", "CLARA has been updated successfully."

19
main.py
View File

@@ -8,6 +8,7 @@ from core.discord_presence import presence
from core.dukto import DuktoProtocol from core.dukto import DuktoProtocol
from core.updater import update_repository, is_update_available from core.updater import update_repository, is_update_available
from core.app_launcher import list_apps from core.app_launcher import list_apps
from core.config import config
from windows.main_window import MainWindow from windows.main_window import MainWindow
@@ -37,10 +38,9 @@ def main():
restart = "--restart" in sys.argv restart = "--restart" in sys.argv
no_quit = "--no-quit" in sys.argv no_quit = "--no-quit" in sys.argv
super_menu = not "--no-super" in sys.argv
noupdate = "--no-update" in sys.argv noupdate = "--no-update" in sys.argv
if not noupdate: if not noupdate and config.get("auto_update", True):
update_available = is_update_available() update_available = is_update_available()
if update_available: if update_available:
update_repository() update_repository()
@@ -50,10 +50,21 @@ def main():
preload_thread.start() preload_thread.start()
dukto_handler = DuktoProtocol() dukto_handler = DuktoProtocol()
dukto_handler.set_ports(
udp_port=config.get("dukto_udp_port", 4644),
tcp_port=config.get("dukto_tcp_port", 4644)
)
pet = MainWindow(dukto_handler=dukto_handler, strings=strings, restart=restart, no_quit=no_quit, super_menu=super_menu) pet = MainWindow(
dukto_handler=dukto_handler,
strings=strings,
config=config,
restart=restart,
no_quit=no_quit,
)
presence.start() if config.get("discord_presence", True):
presence.start()
dukto_handler.initialize() dukto_handler.initialize()
dukto_handler.say_hello() dukto_handler.say_hello()

View File

@@ -3,4 +3,5 @@ pynput
requests requests
beautifulsoup4 beautifulsoup4
GitPython GitPython
discord-rpc pywin32 ; sys_platform == 'win32'
discord-rich-presence

View File

@@ -51,6 +51,7 @@
"share_files_submenu": "File(s)", "share_files_submenu": "File(s)",
"share_text_submenu": "Text", "share_text_submenu": "Text",
"via_browser": "Via Browser...", "via_browser": "Via Browser...",
"settings": "Settings",
"check_updates": "Check for updates", "check_updates": "Check for updates",
"restart": "Restart", "restart": "Restart",
"toggle_visibility": "Hide/Show", "toggle_visibility": "Hide/Show",
@@ -108,5 +109,18 @@
"update_failed_title": "Update Failed", "update_failed_title": "Update Failed",
"update_failed_text": "{message}" "update_failed_text": "{message}"
} }
},
"config_window": {
"title": "Settings",
"hotkey_label": "Global Hotkey:",
"discord_presence_label": "Enable Discord Presence:",
"auto_update_label": "Enable Auto-Update:",
"http_share_port_label": "HTTP Share Port:",
"dukto_udp_port_label": "Dukto UDP Port:",
"dukto_tcp_port_label": "Dukto TCP Port:",
"search_engine_label": "Web Search Engine:",
"restart_note": "Note: Some changes (like ports or hotkey) may require a restart to take effect.",
"reset_title": "Confirm Reset",
"reset_text": "Are you sure you want to reset all settings to their default values?"
} }
} }

View File

@@ -51,6 +51,7 @@
"share_files_submenu": "File(s)", "share_files_submenu": "File(s)",
"share_text_submenu": "Message", "share_text_submenu": "Message",
"via_browser": "Via Browser...", "via_browser": "Via Browser...",
"settings": "Preferences",
"check_updates": "Check for Updates", "check_updates": "Check for Updates",
"restart": "Restart", "restart": "Restart",
"toggle_visibility": "Hide/Show", "toggle_visibility": "Hide/Show",
@@ -108,5 +109,18 @@
"update_failed_title": "Update Failed", "update_failed_title": "Update Failed",
"update_failed_text": "{message}" "update_failed_text": "{message}"
} }
},
"config_window": {
"title": "Preferences",
"hotkey_label": "Global Hotkey:",
"discord_presence_label": "Show CLARA on your Discord status:",
"auto_update_label": "Update Automatically:",
"http_share_port_label": "Browser Sharing Port:",
"dukto_udp_port_label": "Dukto Discovery Port (UDP):",
"dukto_tcp_port_label": "Dukto Transfer Port (TCP):",
"search_engine_label": "Search Engine:",
"restart_note": "Just a heads-up: some changes (like ports or the hotkey) will need a restart to work.",
"reset_title": "Reset Settings?",
"reset_text": "Are you sure you want to go back to the default settings?"
} }
} }

View File

@@ -102,12 +102,15 @@ class AppLauncherDialog(QtWidgets.QDialog):
return return
text_lower = text.lower() text_lower = text.lower()
text_for_command = text_lower.replace(' ', '').replace('-', '').replace('_', '')
filtered_apps = [ filtered_apps = [
app for app in self.apps if app for app in self.apps if
text_lower in app.name.lower() or text_lower in app.name.lower() or
(app.generic_name and text_lower in app.generic_name.lower()) or (app.generic_name and text_lower in app.generic_name.lower()) or
(app.comment and text_lower in app.comment.lower()) or (app.comment and text_lower in app.comment.lower()) or
(app.command and text_lower in app.command.lower()) (app.command and text_for_command in app.command.lower().replace(' ', '').replace('-', '').replace('_', '')) or
(app.keywords and any(text_lower in keyword.lower() for keyword in app.keywords))
] ]
self.populate_list(filtered_apps) self.populate_list(filtered_apps)

88
windows/config_window.py Normal file
View File

@@ -0,0 +1,88 @@
from PySide6 import QtWidgets
from core.config import Config
class ConfigWindow(QtWidgets.QDialog):
def __init__(self, strings, config: Config, parent=None):
super().__init__(parent)
self.strings = strings.get("config_window", {})
self.config = config
self.setWindowTitle(self.strings.get("title", "Settings"))
self.setMinimumWidth(400)
self.layout = QtWidgets.QVBoxLayout(self) # type: ignore
self.form_layout = QtWidgets.QFormLayout()
# Create widgets for each setting
self.hotkey_input = QtWidgets.QLineEdit()
self.discord_presence_check = QtWidgets.QCheckBox()
self.auto_update_check = QtWidgets.QCheckBox()
self.http_port_spin = QtWidgets.QSpinBox()
self.http_port_spin.setRange(1024, 65535)
self.dukto_udp_port_spin = QtWidgets.QSpinBox()
self.dukto_udp_port_spin.setRange(1024, 65535)
self.dukto_tcp_port_spin = QtWidgets.QSpinBox()
self.dukto_tcp_port_spin.setRange(1024, 65535)
self.search_engine_combo = QtWidgets.QComboBox()
self.search_engine_combo.addItems(["brave", "google"])
# Add widgets to layout
self.form_layout.addRow(self.strings.get("hotkey_label", "Global Hotkey:"), self.hotkey_input)
self.form_layout.addRow(self.strings.get("discord_presence_label", "Enable Discord Presence:"), self.discord_presence_check)
self.form_layout.addRow(self.strings.get("auto_update_label", "Enable Auto-Update:"), self.auto_update_check)
self.form_layout.addRow(self.strings.get("http_share_port_label", "HTTP Share Port:"), self.http_port_spin)
self.form_layout.addRow(self.strings.get("dukto_udp_port_label", "Dukto UDP Port:"), self.dukto_udp_port_spin)
self.form_layout.addRow(self.strings.get("dukto_tcp_port_label", "Dukto TCP Port:"), self.dukto_tcp_port_spin)
self.form_layout.addRow(self.strings.get("search_engine_label", "Web Search Engine:"), self.search_engine_combo)
self.layout.addLayout(self.form_layout) #type: ignore
# Info label
self.info_label = QtWidgets.QLabel(self.strings.get("restart_note", "Note: Some changes may require a restart."))
self.info_label.setStyleSheet("font-style: italic; color: grey;")
self.info_label.setWordWrap(True)
self.layout.addWidget(self.info_label) #type: ignore
# Buttons
self.button_box = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.Save | QtWidgets.QDialogButtonBox.Cancel | QtWidgets.QDialogButtonBox.Reset # type: ignore
)
self.button_box.accepted.connect(self.save_config)
self.button_box.rejected.connect(self.reject)
reset_button = self.button_box.button(QtWidgets.QDialogButtonBox.Reset) # type: ignore
if reset_button:
reset_button.clicked.connect(self.reset_to_defaults)
self.layout.addWidget(self.button_box) #type: ignore
self.load_config()
def load_config(self):
self.hotkey_input.setText(self.config.get("hotkey", ""))
self.discord_presence_check.setChecked(self.config.get("discord_presence", True))
self.auto_update_check.setChecked(self.config.get("auto_update", True))
self.http_port_spin.setValue(self.config.get("http_share_port", 8080))
self.dukto_udp_port_spin.setValue(self.config.get("dukto_udp_port", 4644))
self.dukto_tcp_port_spin.setValue(self.config.get("dukto_tcp_port", 4644))
self.search_engine_combo.setCurrentText(self.config.get("search_engine", "brave"))
def save_config(self):
self.config.set("hotkey", self.hotkey_input.text())
self.config.set("discord_presence", self.discord_presence_check.isChecked())
self.config.set("auto_update", self.auto_update_check.isChecked())
self.config.set("http_share_port", self.http_port_spin.value())
self.config.set("dukto_udp_port", self.dukto_udp_port_spin.value())
self.config.set("dukto_tcp_port", self.dukto_tcp_port_spin.value())
self.config.set("search_engine", self.search_engine_combo.currentText())
self.accept()
def reset_to_defaults(self):
reply = QtWidgets.QMessageBox.question(
self,
self.strings.get("reset_title", "Confirm Reset"),
self.strings.get("reset_text", "Are you sure?"),
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, # type: ignore
QtWidgets.QMessageBox.No # type: ignore
)
if reply == QtWidgets.QMessageBox.Yes: # type: ignore
self.config.reset()
self.load_config()

View File

@@ -10,12 +10,14 @@ from core.dukto import Peer
from core.file_search import find from core.file_search import find
from core.web_search import MullvadLetaWrapper from core.web_search import MullvadLetaWrapper
from core.http_share import FileShareServer from core.http_share import FileShareServer
from core.config import Config
from windows.app_launcher import AppLauncherDialog from windows.app_launcher import AppLauncherDialog
from windows.file_search import FileSearchResults from windows.file_search import FileSearchResults
from windows.web_results import WebSearchResults from windows.web_results import WebSearchResults
from windows.text_viewer import TextViewerDialog from windows.text_viewer import TextViewerDialog
from windows.calculator import CalculatorDialog from windows.calculator import CalculatorDialog
from windows.config_window import ConfigWindow
ASSET = Path(__file__).parent.parent / "assets" / "2ktan.png" ASSET = Path(__file__).parent.parent / "assets" / "2ktan.png"
@@ -38,10 +40,15 @@ class MainWindow(QtWidgets.QMainWindow):
http_download_signal = QtCore.Signal(str, str) http_download_signal = QtCore.Signal(str, str)
def __init__(self, dukto_handler, strings, restart=False, no_quit=False, super_menu=True): def __init__(self, dukto_handler, strings, config: Config, restart=False, no_quit=False):
super().__init__() super().__init__()
self.strings = strings self.strings = strings
self.config = config
self.listener = None
self.restart = restart
self.no_quit = no_quit
flags = ( flags = (
QtCore.Qt.FramelessWindowHint #type: ignore QtCore.Qt.FramelessWindowHint #type: ignore
@@ -64,12 +71,12 @@ class MainWindow(QtWidgets.QMainWindow):
mask = QtGui.QBitmap.fromImage(mask_img) mask = QtGui.QBitmap.fromImage(mask_img)
self.setMask(mask) self.setMask(mask)
self.super_menu = super_menu
self.dukto_handler = dukto_handler self.dukto_handler = dukto_handler
self.progress_dialog = None self.progress_dialog = None
# HTTP file sharing # HTTP file sharing
self.http_share = FileShareServer(port=8080) http_port = self.config.get("http_share_port", 8080)
self.http_share = FileShareServer(port=http_port)
self.http_share.on_download = lambda filename, ip: self.http_download_signal.emit(filename, ip) self.http_share.on_download = lambda filename, ip: self.http_download_signal.emit(filename, ip)
# Connect Dukto callbacks to emit signals # Connect Dukto callbacks to emit signals
@@ -109,7 +116,8 @@ class MainWindow(QtWidgets.QMainWindow):
# Super key # Super key
self.show_menu_signal.connect(self.show_menu) self.show_menu_signal.connect(self.show_menu)
self.start_hotkey_listener() if config.get("hotkey") != None and config.get("hotkey") != "none" and config.get("hotkey") != "":
self.start_hotkey_listener()
def build_menus(self): def build_menus(self):
s = self.strings["main_window"]["right_menu"] s = self.strings["main_window"]["right_menu"]
@@ -139,13 +147,14 @@ class MainWindow(QtWidgets.QMainWindow):
self.share_text_submenu_right = share_menu_right.addMenu(s["share_text_submenu"]) self.share_text_submenu_right = share_menu_right.addMenu(s["share_text_submenu"])
self.stop_share_action_right = share_menu_right.addAction("Stop Browser Share", self.stop_browser_share) self.stop_share_action_right = share_menu_right.addAction("Stop Browser Share", self.stop_browser_share)
right_menu.addSeparator() right_menu.addSeparator()
right_menu.addAction(s.get("settings", "Settings"), self.start_config_window)
right_menu.addAction(s["check_updates"], self.update_git) right_menu.addAction(s["check_updates"], self.update_git)
if "--restart" in sys.argv: if self.restart:
right_menu.addAction(s["restart"], self.restart_application) right_menu.addAction(s["restart"], self.restart_application)
right_menu.addAction(s["toggle_visibility"], self.toggle_visible) right_menu.addAction(s["toggle_visibility"], self.toggle_visible)
right_menu.addSeparator() right_menu.addSeparator()
if "--no-quit" not in sys.argv: if not self.no_quit:
right_menu.addAction(s["quit"], QtWidgets.QApplication.quit) right_menu.addAction(s["quit"], QtWidgets.QApplication.quit)
self.tray.setContextMenu(right_menu) self.tray.setContextMenu(right_menu)
@@ -200,23 +209,47 @@ class MainWindow(QtWidgets.QMainWindow):
def show_menu(self): def show_menu(self):
self.left_menu.popup(QtGui.QCursor.pos()) self.left_menu.popup(QtGui.QCursor.pos())
def on_press(self, key): def start_hotkey_listener(self):
if self.super_menu: hotkey_str = self.config.get("hotkey", "super")
if key == keyboard.Key.cmd:
def on_activate():
if self.isVisible():
self.show_menu_signal.emit() self.show_menu_signal.emit()
def start_hotkey_listener(self): key_map = {
self.listener = keyboard.Listener(on_press=self.on_press) "super": "cmd",
self.listener.start() "ctrl": "ctrl",
"space": "space",
}
try:
keys = [key_map.get(k.strip().lower(), k.strip().lower()) for k in hotkey_str.split('+')]
formatted_hotkey = '<' + '>+<'.join(keys) + '>' #type: ignore
hotkey = keyboard.HotKey(
keyboard.HotKey.parse(formatted_hotkey),
on_activate
)
self.listener = keyboard.Listener(
on_press=hotkey.press, #type: ignore
on_release=hotkey.release #type: ignore
)
self.listener.start()
except Exception as e:
print(f"Failed to set hotkey '{hotkey_str}': {e}. Hotkey will be disabled.")
self.listener = None
def closeEvent(self, event): def closeEvent(self, event):
self.listener.stop() if self.listener:
self.listener.stop()
if self.http_share.is_running(): if self.http_share.is_running():
self.http_share.stop() self.http_share.stop()
super().closeEvent(event) super().closeEvent(event)
def ensure_on_top(self): def ensure_on_top(self):
if self.isVisible(): if self.isVisible() and not self.left_menu.isVisible() and not self.tray.contextMenu().isVisible():
self.raise_() self.raise_()
def showEvent(self, event): def showEvent(self, event):
@@ -463,6 +496,10 @@ class MainWindow(QtWidgets.QMainWindow):
self.calculator_dialog.move(QtGui.QCursor.pos()) self.calculator_dialog.move(QtGui.QCursor.pos())
self.calculator_dialog.show() self.calculator_dialog.show()
def start_config_window(self):
self.config_dialog = ConfigWindow(self.strings, self.config, self)
self.config_dialog.show()
def start_file_search(self): def start_file_search(self):
s = self.strings["file_search"] s = self.strings["file_search"]
dialog = QtWidgets.QInputDialog(self) dialog = QtWidgets.QInputDialog(self)
@@ -518,7 +555,8 @@ class MainWindow(QtWidgets.QMainWindow):
if ok and query: if ok and query:
try: try:
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) #type: ignore QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) #type: ignore
leta = MullvadLetaWrapper(engine="brave") search_engine = self.config.get("search_engine", "brave")
leta = MullvadLetaWrapper(engine=search_engine)
results = leta.search(query) results = leta.search(query)
if results and results.get('results'): if results and results.get('results'):