Compare commits
18 Commits
5b67d6599b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa2c23537c | ||
|
|
69b0fb3226 | ||
|
|
1814054cc2 | ||
|
|
3b39ed1806 | ||
|
|
754acac08a | ||
|
|
e0ade52eb5 | ||
|
|
0b115bfe27 | ||
|
|
9fa55dc02e | ||
|
|
2c42c88555 | ||
|
|
795cdb9daf | ||
|
|
b6becb6912 | ||
|
|
a6a83bf70e | ||
|
|
790c9c3778 | ||
|
|
75cc6241aa | ||
|
|
8c42dfffbd | ||
|
|
620f6db259 | ||
|
|
aa3741e357 | ||
|
|
02755e7d63 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,5 +9,6 @@ __pycache__/
|
|||||||
*.pyc.*
|
*.pyc.*
|
||||||
*.pyo.*
|
*.pyo.*
|
||||||
|
|
||||||
# SUPERCOPY
|
SUPERCOPY.py
|
||||||
copy.md
|
copy.md
|
||||||
|
.vscode
|
||||||
13
README.md
13
README.md
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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
|
||||||
285
SUPERCOPY.py
285
SUPERCOPY.py
@@ -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',
|
||||||
|
}
|
||||||
|
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
return extension_map.get(ext, '')
|
||||||
|
|
||||||
copy = ""
|
def should_exclude(file_path, root_dir):
|
||||||
|
"""Determine if a file should be excluded from copying."""
|
||||||
|
abs_path = os.path.abspath(file_path)
|
||||||
|
rel_path = os.path.relpath(abs_path, root_dir)
|
||||||
|
rel_path_forward = rel_path.replace(os.sep, '/')
|
||||||
|
basename = os.path.basename(file_path)
|
||||||
|
|
||||||
|
# Exclude specific files
|
||||||
|
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
|
||||||
|
|
||||||
for file in files:
|
def load_gitignore_patterns(root_dir):
|
||||||
with open(file, "r", encoding="utf-8") as f:
|
"""Load patterns from .gitignore file."""
|
||||||
lines = f.readlines()
|
patterns = []
|
||||||
copy += f"### {file}\n\n"
|
gitignore_path = os.path.join(root_dir, '.gitignore')
|
||||||
copy += f"{codeblock}python\n"
|
|
||||||
copy += "".join(lines)
|
if os.path.isfile(gitignore_path):
|
||||||
copy += f"\n{codeblock}\n\n"
|
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
|
||||||
|
|
||||||
with open("copy.md", "w", encoding="utf-8") as f:
|
def is_ignored(path, patterns, root_dir):
|
||||||
f.write(copy)
|
"""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()
|
||||||
@@ -8,9 +8,9 @@ import shlex
|
|||||||
|
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
try:
|
try:
|
||||||
from win32com.client import Dispatch
|
from win32com.client import Dispatch # type: ignore
|
||||||
import win32api
|
import win32api # type: ignore
|
||||||
import win32con
|
import win32con # type: ignore
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print("Windows specific functionality requires 'pywin32'. Please run 'pip install pywin32'.")
|
print("Windows specific functionality requires 'pywin32'. Please run 'pip install pywin32'.")
|
||||||
Dispatch = None
|
Dispatch = None
|
||||||
@@ -20,7 +20,7 @@ if platform.system() == "Windows":
|
|||||||
_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
|
||||||
@@ -28,9 +28,10 @@ class App:
|
|||||||
self.generic_name = generic_name
|
self.generic_name = generic_name
|
||||||
self.comment = comment
|
self.comment = comment
|
||||||
self.command = command if command else os.path.basename(exec.split(' ')[0])
|
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_linux():
|
def get_desktop_dirs_linux():
|
||||||
dirs = [
|
dirs = [
|
||||||
@@ -78,6 +79,9 @@ 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:
|
||||||
apps.append(App(
|
apps.append(App(
|
||||||
name=main_name,
|
name=main_name,
|
||||||
@@ -85,7 +89,8 @@ def parse_desktop_file(file_path: Path) -> list[App]:
|
|||||||
icon=main_entry.get('Icon', ''),
|
icon=main_entry.get('Icon', ''),
|
||||||
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', ''),
|
||||||
|
keywords=keywords
|
||||||
))
|
))
|
||||||
|
|
||||||
if 'Actions' in main_entry:
|
if 'Actions' in main_entry:
|
||||||
@@ -102,7 +107,8 @@ def parse_desktop_file(file_path: Path) -> list[App]:
|
|||||||
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', ''),
|
||||||
|
keywords=keywords
|
||||||
))
|
))
|
||||||
return apps
|
return apps
|
||||||
|
|
||||||
@@ -187,14 +193,9 @@ def launch(app: App):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Using ShellExecute is more robust for launching various application types on Windows,
|
|
||||||
# as it leverages the Windows shell's own mechanisms. This is particularly helpful for
|
|
||||||
# non-standard executables like PWAs or Microsoft Store apps.
|
|
||||||
command_parts = shlex.split(app.exec, posix=False)
|
command_parts = shlex.split(app.exec, posix=False)
|
||||||
target = command_parts[0]
|
target = command_parts[0]
|
||||||
|
|
||||||
# Use subprocess.list2cmdline to correctly re-assemble the arguments string,
|
|
||||||
# preserving quotes around arguments with spaces.
|
|
||||||
arguments = subprocess.list2cmdline(command_parts[1:])
|
arguments = subprocess.list2cmdline(command_parts[1:])
|
||||||
|
|
||||||
win32api.ShellExecute(
|
win32api.ShellExecute(
|
||||||
|
|||||||
82
core/config.py
Normal file
82
core/config.py
Normal 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()
|
||||||
@@ -25,216 +25,81 @@ 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;
|
def _generate_shared_text_html(self, text: str) -> str:
|
||||||
padding: 20px;
|
if not text:
|
||||||
line-height: 1.4;
|
return ""
|
||||||
font-size: 14px;
|
|
||||||
}}
|
escaped_text = html.escape(text)
|
||||||
.container {{
|
return f'''<h2>Shared Text</h2>
|
||||||
width: 760px;
|
<p>Select the text below and copy it to your clipboard.</p>
|
||||||
max-width: 98%;
|
<textarea class="share-text" readonly="readonly">{escaped_text}</textarea>'''
|
||||||
margin: 0 auto;
|
|
||||||
background: #ffffff;
|
def _generate_shared_files_html(self, files: List[str]) -> str:
|
||||||
border: 1px solid #cfcfcf;
|
"""Generate HTML for shared files section."""
|
||||||
padding: 16px;
|
if not files:
|
||||||
}}
|
return ""
|
||||||
.header {{
|
|
||||||
padding-bottom: 12px;
|
rows = ""
|
||||||
border-bottom: 2px solid #e6e6e6;
|
for i, filepath in enumerate(files):
|
||||||
overflow: hidden;
|
try:
|
||||||
}}
|
path = Path(filepath)
|
||||||
.brand {{
|
if path.exists() and path.is_file():
|
||||||
float: left;
|
name = html.escape(path.name)
|
||||||
font-weight: bold;
|
size = format_size(path.stat().st_size)
|
||||||
font-size: 20px;
|
rows += f'''<tr>
|
||||||
color: #2b65a3;
|
<td>{name}</td>
|
||||||
}}
|
<td>{size}</td>
|
||||||
.subtitle {{
|
<td><a class="button" href="/download/{i}">Download</a></td>
|
||||||
float: right;
|
</tr>'''
|
||||||
color: #666;
|
except Exception:
|
||||||
font-size: 12px;
|
continue
|
||||||
margin-top: 4px;
|
|
||||||
text-align: right;
|
if not rows:
|
||||||
}}
|
return ""
|
||||||
.main {{
|
|
||||||
margin-top: 16px;
|
return f'''<h2>Shared Files</h2>
|
||||||
overflow: hidden;
|
<p>Click a button to download the corresponding file.</p>
|
||||||
}}
|
<table class="file-list" cellpadding="0" cellspacing="0">
|
||||||
.left-col {{
|
<tr><th>Filename</th><th>Size</th><th>Action</th></tr>
|
||||||
float: left;
|
{rows}
|
||||||
width: 60%;
|
</table>'''
|
||||||
min-width: 300px;
|
|
||||||
}}
|
def _get_base_html(self, hostname: str, url: str, total_size_info: str,
|
||||||
.right-col {{
|
no_content_display: str, shared_text_html: str, shared_files_html: str) -> str:
|
||||||
float: left;
|
template = self.load_html_template()
|
||||||
width: 36%;
|
|
||||||
min-width: 160px;
|
replacements = {
|
||||||
margin-left: 12px;
|
'{{TITLE}}': 'CLARA Share',
|
||||||
}}
|
'{{HOSTNAME}}': html.escape(hostname),
|
||||||
.section {{ margin-bottom: 18px; }}
|
'{{URL}}': html.escape(url),
|
||||||
h2 {{
|
'{{TOTAL_SIZE_INFO}}': total_size_info,
|
||||||
font-size: 16px;
|
'{{NO_CONTENT_DISPLAY}}': no_content_display,
|
||||||
margin: 6px 0 10px 0;
|
'{{SHARED_TEXT_HTML}}': shared_text_html,
|
||||||
color: #333;
|
'{{SHARED_FILES_HTML}}': shared_files_html
|
||||||
}}
|
}
|
||||||
p {{ margin: 8px 0; color: #444; }}
|
|
||||||
table.file-list {{
|
result = template
|
||||||
width: 100%;
|
for placeholder, value in replacements.items():
|
||||||
border-collapse: collapse;
|
result = result.replace(placeholder, value)
|
||||||
border-spacing: 0;
|
|
||||||
}}
|
return result
|
||||||
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 & 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) {{
|
|
||||||
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);
|
|
||||||
}}
|
|
||||||
|
|
||||||
if (typeof initialDataJSON !== 'undefined' && initialDataJSON) {{
|
|
||||||
lastData = initialDataJSON;
|
|
||||||
try {{
|
|
||||||
var initialData = JSON.parse(initialDataJSON);
|
|
||||||
updateContent(initialData);
|
|
||||||
}} catch (e) {{}}
|
|
||||||
}}
|
|
||||||
|
|
||||||
setInterval(fetchData, 5000);
|
|
||||||
}})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if self.path == '/':
|
if self.path == '/':
|
||||||
self.send_combined_index_page()
|
self.send_combined_index_page()
|
||||||
@@ -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>
|
# Generate HTML server-side
|
||||||
</div>
|
shared_text_html = self._generate_shared_text_html(self.shared_text or "")
|
||||||
<div class="right-col">
|
shared_files_html = self._generate_shared_files_html(self.shared_files)
|
||||||
<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()
|
|
||||||
json_string = json.dumps(initial_data_dict)
|
|
||||||
initial_data_script = f'<script type="text/javascript">var initialDataJSON = {json.dumps(json_string)};</script>'
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -434,6 +286,8 @@ class FileShareServer:
|
|||||||
for f in files:
|
for f in files:
|
||||||
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
|
||||||
|
|||||||
243
core/share.html
Normal file
243
core/share.html
Normal 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 & 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>
|
||||||
@@ -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
19
main.py
@@ -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 and not "--no-start" 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()
|
||||||
|
|||||||
@@ -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?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
88
windows/config_window.py
Normal 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()
|
||||||
@@ -23,4 +23,4 @@ class FileSearchResults(QtWidgets.QDialog):
|
|||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
directory = os.path.dirname(file_path)
|
directory = os.path.dirname(file_path)
|
||||||
url = QtCore.QUrl.fromLocalFile(directory)
|
url = QtCore.QUrl.fromLocalFile(directory)
|
||||||
QtGui.QDesktopServices.openUrl(url)
|
QtGui.QDesktopServices.openUrl(url)
|
||||||
@@ -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,17 +209,41 @@ 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)
|
||||||
@@ -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'):
|
||||||
|
|||||||
Reference in New Issue
Block a user