Compare commits

..

12 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
13 changed files with 582 additions and 79 deletions

3
.gitignore vendored
View File

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

View File

@@ -12,7 +12,7 @@ A WIP desktop assistant for Linux and Windows.
- File search
- Web search
- Updater
- Super key menu (Doesn't work properly on Windows)
- 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)
- Browser based file and text transfer (HTTP)
- Discord Rich Presence integration
@@ -20,7 +20,7 @@ A WIP desktop assistant for Linux and Windows.
## Requirements
- Python 3
- X11 Desktop
- X11 Desktop (Linux only)
- [`fd`](https://github.com/sharkdp/fd)
- `git`
@@ -32,7 +32,7 @@ A WIP desktop assistant for Linux and Windows.
4. Launch `main.py`. Available options:
- `--restart` Add a restart option to the right click menu
- `--no-quit` Hide the quit option
- `--no-super`/`--no-start` Disable the super key menu (recommended on Windows)
- `--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

View File

@@ -1,36 +1,257 @@
files = [
"core/app_launcher.py",
"core/discord_presence.py",
"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"
import os
import sys
import fnmatch
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, '')
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
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 = "```"
copy = ""
# Load .gitignore patterns
gitignore_patterns = load_gitignore_patterns(root_dir)
if gitignore_patterns:
print(f"Loaded {len(gitignore_patterns)} patterns from .gitignore")
for file in files:
with open(file, "r", encoding="utf-8") as f:
lines = f.readlines()
copy += f"### {file}\n\n"
copy += f"{codeblock}python\n"
copy += "".join(lines)
copy += f"\n{codeblock}\n\n"
def is_output_file(path):
return os.path.abspath(path) == os.path.abspath(output_file)
with open("copy.md", "w", encoding="utf-8") as f:
f.write(copy)
# 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

@@ -193,14 +193,9 @@ def launch(app: App):
return
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)
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:])
win32api.ShellExecute(

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

@@ -41,8 +41,47 @@ class FileShareHandler(BaseHTTPRequestHandler):
raise RuntimeError(f"HTML template not found at {template_path}")
return cls.html_template
def _generate_shared_text_html(self, text: str) -> str:
if not text:
return ""
escaped_text = html.escape(text)
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>'''
def _generate_shared_files_html(self, files: List[str]) -> str:
"""Generate HTML for shared files section."""
if not files:
return ""
rows = ""
for i, filepath in enumerate(files):
try:
path = Path(filepath)
if path.exists() and path.is_file():
name = html.escape(path.name)
size = format_size(path.stat().st_size)
rows += f'''<tr>
<td>{name}</td>
<td>{size}</td>
<td><a class="button" href="/download/{i}">Download</a></td>
</tr>'''
except Exception:
continue
if not rows:
return ""
return f'''<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>'''
def _get_base_html(self, hostname: str, url: str, total_size_info: str,
no_content_display: str, initial_data_json: str) -> str:
no_content_display: str, shared_text_html: str, shared_files_html: str) -> str:
template = self.load_html_template()
replacements = {
@@ -51,7 +90,8 @@ class FileShareHandler(BaseHTTPRequestHandler):
'{{URL}}': html.escape(url),
'{{TOTAL_SIZE_INFO}}': total_size_info,
'{{NO_CONTENT_DISPLAY}}': no_content_display,
'{{INITIAL_DATA_JSON}}': initial_data_json
'{{SHARED_TEXT_HTML}}': shared_text_html,
'{{SHARED_FILES_HTML}}': shared_files_html
}
result = template
@@ -106,19 +146,20 @@ class FileShareHandler(BaseHTTPRequestHandler):
if total_size_bytes > 0:
total_size_info = format_size(total_size_bytes)
initial_data_dict = self._get_api_data_dict()
json_string = json.dumps(initial_data_dict)
initial_data_json = json.dumps(json_string)
url = f"http://{self._get_local_ip()}:{self.server.server_address[1]}/" #type: ignore
no_content_display = 'none' if has_content else 'block'
# Generate HTML server-side
shared_text_html = self._generate_shared_text_html(self.shared_text or "")
shared_files_html = self._generate_shared_files_html(self.shared_files)
html_content = self._get_base_html(
hostname=hostname,
url=url,
total_size_info=total_size_info,
no_content_display=no_content_display,
initial_data_json=initial_data_json
shared_text_html=shared_text_html,
shared_files_html=shared_files_html
).encode('utf-8')
self.send_response(200)
@@ -246,6 +287,8 @@ class FileShareServer:
if f not in current_files:
self.shared_files.append(f)
FileShareHandler.shared_files = self.shared_files
def share_text(self, text: str) -> str:
self.shared_text = text
FileShareHandler.shared_text = self.shared_text

View File

@@ -147,8 +147,12 @@
</div>
<div class="main clearfix">
<div class="left-col">
<div class="section" id="shared-text-container"></div>
<div class="section" id="shared-files-container"></div>
<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">
@@ -167,7 +171,6 @@
<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">var initialDataJSON = {{INITIAL_DATA_JSON}};</script>
<script type="text/javascript">
(function() {
var lastData = '';
@@ -232,14 +235,7 @@
xhr.send(null);
}
if (typeof initialDataJSON !== 'undefined' && initialDataJSON) {
lastData = initialDataJSON;
try {
var initialData = JSON.parse(initialDataJSON);
updateContent(initialData);
} catch (e) {}
}
// Refresh data every 5 seconds
setInterval(fetchData, 5000);
})();
</script>

17
main.py
View File

@@ -8,6 +8,7 @@ from core.discord_presence import presence
from core.dukto import DuktoProtocol
from core.updater import update_repository, is_update_available
from core.app_launcher import list_apps
from core.config import config
from windows.main_window import MainWindow
@@ -37,10 +38,9 @@ def main():
restart = "--restart" 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
if not noupdate:
if not noupdate and config.get("auto_update", True):
update_available = is_update_available()
if update_available:
update_repository()
@@ -50,9 +50,20 @@ def main():
preload_thread.start()
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,
)
if config.get("discord_presence", True):
presence.start()
dukto_handler.initialize()

View File

@@ -51,6 +51,7 @@
"share_files_submenu": "File(s)",
"share_text_submenu": "Text",
"via_browser": "Via Browser...",
"settings": "Settings",
"check_updates": "Check for updates",
"restart": "Restart",
"toggle_visibility": "Hide/Show",
@@ -108,5 +109,18 @@
"update_failed_title": "Update Failed",
"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_text_submenu": "Message",
"via_browser": "Via Browser...",
"settings": "Preferences",
"check_updates": "Check for Updates",
"restart": "Restart",
"toggle_visibility": "Hide/Show",
@@ -108,5 +109,18 @@
"update_failed_title": "Update Failed",
"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?"
}
}

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.web_search import MullvadLetaWrapper
from core.http_share import FileShareServer
from core.config import Config
from windows.app_launcher import AppLauncherDialog
from windows.file_search import FileSearchResults
from windows.web_results import WebSearchResults
from windows.text_viewer import TextViewerDialog
from windows.calculator import CalculatorDialog
from windows.config_window import ConfigWindow
ASSET = Path(__file__).parent.parent / "assets" / "2ktan.png"
@@ -38,10 +40,15 @@ class MainWindow(QtWidgets.QMainWindow):
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__()
self.strings = strings
self.config = config
self.listener = None
self.restart = restart
self.no_quit = no_quit
flags = (
QtCore.Qt.FramelessWindowHint #type: ignore
@@ -64,12 +71,12 @@ class MainWindow(QtWidgets.QMainWindow):
mask = QtGui.QBitmap.fromImage(mask_img)
self.setMask(mask)
self.super_menu = super_menu
self.dukto_handler = dukto_handler
self.progress_dialog = None
# 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)
# Connect Dukto callbacks to emit signals
@@ -109,6 +116,7 @@ class MainWindow(QtWidgets.QMainWindow):
# Super key
self.show_menu_signal.connect(self.show_menu)
if config.get("hotkey") != None and config.get("hotkey") != "none" and config.get("hotkey") != "":
self.start_hotkey_listener()
def build_menus(self):
@@ -139,13 +147,14 @@ class MainWindow(QtWidgets.QMainWindow):
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)
right_menu.addSeparator()
right_menu.addAction(s.get("settings", "Settings"), self.start_config_window)
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["toggle_visibility"], self.toggle_visible)
right_menu.addSeparator()
if "--no-quit" not in sys.argv:
if not self.no_quit:
right_menu.addAction(s["quit"], QtWidgets.QApplication.quit)
self.tray.setContextMenu(right_menu)
@@ -200,16 +209,40 @@ class MainWindow(QtWidgets.QMainWindow):
def show_menu(self):
self.left_menu.popup(QtGui.QCursor.pos())
def on_press(self, key):
if self.super_menu:
if key == keyboard.Key.cmd:
def start_hotkey_listener(self):
hotkey_str = self.config.get("hotkey", "super")
def on_activate():
if self.isVisible():
self.show_menu_signal.emit()
def start_hotkey_listener(self):
self.listener = keyboard.Listener(on_press=self.on_press)
key_map = {
"super": "cmd",
"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):
if self.listener:
self.listener.stop()
if self.http_share.is_running():
self.http_share.stop()
@@ -463,6 +496,10 @@ class MainWindow(QtWidgets.QMainWindow):
self.calculator_dialog.move(QtGui.QCursor.pos())
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):
s = self.strings["file_search"]
dialog = QtWidgets.QInputDialog(self)
@@ -518,7 +555,8 @@ class MainWindow(QtWidgets.QMainWindow):
if ok and query:
try:
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)
if results and results.get('results'):