Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,14 @@ logs/



# AGENTS 知识库(本地参考用)
AGENTS.md
**/AGENTS.md

# 本地 llama 动态库(Linux 测试环境)
util/llama/bin/*.so
util/llama/bin/*.so.*

# 本地配置文件
.claude
.vscode
Expand All @@ -187,4 +195,4 @@ file_*.txt

release
*.dll
*.exe
*.exe
6 changes: 6 additions & 0 deletions build-icon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from PIL import Image
import sys

img = Image.open('assets/icon.ico')
img = img.resize((256, 256), Image.LANCZOS)
img.save(sys.argv[1])
52 changes: 52 additions & 0 deletions build-linux.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT"

PYTHON=".venv/bin/python"
DIST="$ROOT/dist"
RELEASE="$ROOT/release"
WITH_TRAY=false

for arg in "$@"; do
case "$arg" in
--with-tray) WITH_TRAY=true ;;
esac
done

mkdir -p "$RELEASE"

echo "===== Building CapsWriter-Offline (Server + Client) ====="
$PYTHON -m PyInstaller --clean --noconfirm build-linux.spec

name="CapsWriter-Offline"
src="$DIST/$name"

if [ ! -d "$src" ]; then
echo "ERROR: $src not found"
exit 1
fi

rm -f "$src/models" 2>/dev/null

if [ "$WITH_TRAY" = true ]; then
echo ""
echo "===== Stripping PySide6 for tray support ====="
$PYTHON "$ROOT/strip_pyside6.py" "$src/internal"
fi

echo ""
echo "===== Creating release archive ====="

archive="$RELEASE/${name}-Linux-$(date +%Y%m%d).zip"
rm -f "$archive"
cd "$DIST"
zip -r -9 "$archive" "$(basename "$src")" \
-x "*/__pycache__/*" "*/.git/*" "*.pyc"
cd "$ROOT"

echo ""
echo "===== Done ====="
echo " $(du -sh "$archive" | cut -f1) $archive"
ls -lh dist/CapsWriter-Offline/start_*
9 changes: 5 additions & 4 deletions core_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,19 @@ async def main_mic() -> None:
break

# 如果处理任务结束(无论是正常还是异常),继续下一轮
# 但 ResultProcessor 应该是一个无限循环,除非出错
if process_task in done:
# 清理未完成的等待任务
if not wait_shutdown.done():
wait_shutdown.cancel()
# 检查是否有关闭请求(可能是 processor 内部触发)
if lifecycle.is_shutting_down:
break
# 如果没有请求退出但任务结束了,可能是异常
# process_loop 正常返回但未请求退出 → 连接失败,等待重试
try:
await process_task
except Exception as e:
logger.error(f"处理循环异常: {e}")
# 防止死循环打印日志
await asyncio.sleep(1)
await asyncio.sleep(3)

except asyncio.CancelledError:
logger.info("主任务被取消,正在退出...")
Expand Down
3 changes: 2 additions & 1 deletion core_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ async def run_websocket_server():
from util.concurrency.daemon_executor import SimpleDaemonExecutor
loop.set_default_executor(SimpleDaemonExecutor())

setup_tray()

# 清空物理内存工作集
# if system() == 'Windows':
# empty_current_working_set()
Expand Down Expand Up @@ -96,7 +98,6 @@ def init():
logger.info(f"版本: {__version__}")
logger.info(f"日志级别: {Config.log_level}")

setup_tray()
print_banner()

try:
Expand Down
31 changes: 31 additions & 0 deletions requirements-client-linux.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Linux 客户端依赖
# basic and cli
rich
typer
colorama
markdown

# system, input, hardware
pynput
sounddevice
watchdog

# network and api
websockets
openai
ollama
httpx

# data process
numpy
pypinyin
srt

# tray (Linux uses PySide6, not pystray)
PySide6-Essentials
shiboken6
Pillow
tkhtmlview

# build
pyinstaller
19 changes: 19 additions & 0 deletions requirements-server-linux.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Linux 服务端依赖
# ASR core
sherpa-onnx
numpy
onnxruntime

# basic
rich
websockets
watchdog
pypinyin

# tray (Linux uses PySide6, not pystray)
PySide6-Essentials
shiboken6
Pillow

markdown
tkhtmlview
156 changes: 156 additions & 0 deletions strip_pyside6.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""
裁剪 PySide6 到最小集:QtCore + QtGui + QtWidgets + X11 平台插件
用于 --with-tray 构建模式,将裁剪后的 PySide6 注入 dist/internal/

用法: python strip_pyside6.py <target_dir>
target_dir: dist/CapsWriter-Offline/internal
"""

import os
import shutil
import sys


KEEP_MODULES = {'QtCore', 'QtGui', 'QtWidgets'}

KEEP_QT_LIBS = {
'libQt6Core.so.6',
'libQt6Gui.so.6',
'libQt6Widgets.so.6',
'libQt6XcbQpa.so.6',
'libQt6DBus.so.6',
'libQt6OpenGL.so.6',
'libQt6Svg.so.6',
}

KEEP_QT_LIB_PREFIXES = [
'libQt6Core', 'libQt6Gui', 'libQt6Widgets',
'libQt6XcbQpa', 'libQt6DBus', 'libQt6OpenGL', 'libQt6Svg',
]

KEEP_PLUGIN_DIRS = {'platforms', 'imageformats', 'iconengines'}

KEEP_TOP_FILES = {
'__init__.py', '_config.py', '_git_pyside_version.py',
'PySide6_Essentials.json',
}

KEEP_TOP_DIRS = {
'support',
}


def matches_prefix(filename, prefixes):
return any(filename.startswith(p) for p in prefixes)


def strip_pyside6(src_root, dst_root):
pyside6_src = os.path.join(src_root, 'PySide6')
shiboken6_src = os.path.join(src_root, 'shiboken6')

if not os.path.isdir(pyside6_src):
print(f"ERROR: {pyside6_src} not found")
sys.exit(1)

total_before = 0
total_after = 0

for dp, dn, fns in os.walk(pyside6_src):
for f in fns:
total_before += os.path.getsize(os.path.join(dp, f))

# --- PySide6 ---
dst_pyside6 = os.path.join(dst_root, 'PySide6')

# Copy only needed .abi3.so + .pyi
for mod in KEEP_MODULES:
for ext in ['.abi3.so', '.pyi']:
src = os.path.join(pyside6_src, f'{mod}{ext}')
if os.path.isfile(src):
os.makedirs(dst_pyside6, exist_ok=True)
shutil.copy2(src, os.path.join(dst_pyside6, f'{mod}{ext}'))
print(f' + {mod}{ext}')

# Copy libpyside (e.g. libpyside6.abi3.so.6.11)
for f in os.listdir(pyside6_src):
if f.startswith('libpyside6') and '.so' in f:
shutil.copy2(os.path.join(pyside6_src, f), os.path.join(dst_pyside6, f))
print(f' + {f}')

# Copy top-level files
for f in os.listdir(pyside6_src):
if f in KEEP_TOP_FILES and os.path.isfile(os.path.join(pyside6_src, f)):
shutil.copy2(os.path.join(pyside6_src, f), os.path.join(dst_pyside6, f))
print(f' + {f}')

# Copy top-level dirs
for d in KEEP_TOP_DIRS:
src_d = os.path.join(pyside6_src, d)
if os.path.isdir(src_d):
shutil.copytree(src_d, os.path.join(dst_pyside6, d))
print(f' + {d}/')

# Copy Qt libs (only needed ones)
qt_lib_src = os.path.join(pyside6_src, 'Qt', 'lib')
qt_lib_dst = os.path.join(dst_pyside6, 'Qt', 'lib')
if os.path.isdir(qt_lib_src):
for f in os.listdir(qt_lib_src):
if matches_prefix(f, KEEP_QT_LIB_PREFIXES):
os.makedirs(qt_lib_dst, exist_ok=True)
shutil.copy2(os.path.join(qt_lib_src, f), os.path.join(qt_lib_dst, f))
print(f' + Qt/lib/{f}')

# Copy ICU (QtGui needs it)
for f in os.listdir(qt_lib_src):
if f.startswith('libicu'):
os.makedirs(qt_lib_dst, exist_ok=True)
shutil.copy2(os.path.join(qt_lib_src, f), os.path.join(qt_lib_dst, f))
print(f' + Qt/lib/{f}')

# Copy plugins (only needed dirs)
qt_plugins_src = os.path.join(pyside6_src, 'Qt', 'plugins')
qt_plugins_dst = os.path.join(dst_pyside6, 'Qt', 'plugins')
if os.path.isdir(qt_plugins_src):
for pd in KEEP_PLUGIN_DIRS:
src_pd = os.path.join(qt_plugins_src, pd)
if os.path.isdir(src_pd):
shutil.copytree(src_pd, os.path.join(qt_plugins_dst, pd))
count = sum(len(fns) for _, _, fns in os.walk(src_pd))
print(f' + Qt/plugins/{pd}/ ({count} files)')

# Copy qt.conf if exists
qt_conf = os.path.join(pyside6_src, 'Qt', 'qt.conf')
if os.path.isfile(qt_conf):
os.makedirs(os.path.join(dst_pyside6, 'Qt'), exist_ok=True)
shutil.copy2(qt_conf, os.path.join(dst_pyside6, 'Qt', 'qt.conf'))

# --- shiboken6 ---
if os.path.isdir(shiboken6_src):
dst_shiboken6 = os.path.join(dst_root, 'shiboken6')
shutil.copytree(shiboken6_src, dst_shiboken6)
print(f' + shiboken6/')

# Calculate after size
for dp, dn, fns in os.walk(dst_pyside6):
for f in fns:
total_after += os.path.getsize(os.path.join(dp, f))

before_mb = total_before / 1024 / 1024
after_mb = total_after / 1024 / 1024
print(f'\nPySide6: {before_mb:.0f}MB → {after_mb:.0f}MB (stripped {before_mb - after_mb:.0f}MB)')


if __name__ == '__main__':
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} <target_internal_dir>")
sys.exit(1)

target = sys.argv[1]
src = os.path.join(os.path.dirname(__file__), '.venv', 'lib', 'python3.12', 'site-packages')
if not os.path.isdir(src):
print(f"ERROR: site-packages not found at {src}")
sys.exit(1)

print(f"Stripping PySide6 → {target}")
strip_pyside6(src, target)
13 changes: 12 additions & 1 deletion util/client/shortcut/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,22 @@
shortcut 子模块

包含快捷键处理相关功能,使用 ShortcutManager 统一管理所有快捷键(键盘和鼠标)。
根据平台自动选择 Windows 或 Linux 实现。
"""

import platform

from .. import logger
from util.client.shortcut.shortcut_config import Shortcut, CommonShortcuts
from util.client.shortcut.shortcut_manager import ShortcutManager

if platform.system() == 'Linux':
from util.client.shortcut.linux_shortcut_manager import LinuxShortcutManager as ShortcutManager
from util.client.shortcut.linux_key_mapper import LinuxKeyMapper as KeyMapper
from util.client.shortcut.linux_key_mapper import RESTORABLE_KEYS
else:
from util.client.shortcut.shortcut_manager import ShortcutManager
from util.client.shortcut.key_mapper import KeyMapper
from util.client.shortcut.key_mapper import RESTORABLE_KEYS

__all__ = [
'logger',
Expand Down
3 changes: 2 additions & 1 deletion util/client/shortcut/emulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from pynput import keyboard, mouse
from . import logger
from util.client.shortcut.key_mapper import KeyMapper



Expand Down Expand Up @@ -39,6 +38,8 @@ def emulate_key(self, key_name: str) -> None:
Args:
key_name: 按键名称(如 'caps_lock', 'f12')
"""
from util.client.shortcut import KeyMapper

self._emulating_keys.add(key_name)

key_obj = KeyMapper.name_to_key(key_name)
Expand Down
Loading