mirror of
https://github.com/nickrunning/wechat-selkies.git
synced 2026-05-09 00:24:09 +00:00
feat: add window switcher
This commit is contained in:
22
Dockerfile
22
Dockerfile
@@ -27,17 +27,9 @@ RUN apt-get update && \
|
||||
libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 \
|
||||
libxcomposite1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 \
|
||||
libxss1 libxtst6 libatomic1 libxcomposite1 libxrender1 libxrandr2 libxkbcommon-x11-0 \
|
||||
libfontconfig1 libdbus-1-3 libnss3 libx11-xcb1
|
||||
libfontconfig1 libdbus-1-3 libnss3 libx11-xcb1 python3-tk
|
||||
|
||||
# Clean up
|
||||
RUN apt-get purge -y --autoremove
|
||||
RUN apt-get autoclean && \
|
||||
rm -rf \
|
||||
/config/.cache \
|
||||
/config/.npm \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/* \
|
||||
/tmp/*
|
||||
RUN pip install --no-cache-dir python-xlib
|
||||
|
||||
# Install WeChat based on target architecture
|
||||
RUN case "$TARGETPLATFORM" in \
|
||||
@@ -59,6 +51,16 @@ RUN case "$TARGETPLATFORM" in \
|
||||
rm -f wechat.deb && \
|
||||
echo "✅ WeChat installation completed for $WECHAT_ARCH"
|
||||
|
||||
# Clean up
|
||||
RUN apt-get purge -y --autoremove
|
||||
RUN apt-get autoclean && \
|
||||
rm -rf \
|
||||
/config/.cache \
|
||||
/config/.npm \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/* \
|
||||
/tmp/*
|
||||
|
||||
# set app name
|
||||
ENV TITLE="WeChat-Selkies"
|
||||
ENV TZ="Asia/Shanghai"
|
||||
|
||||
@@ -1 +1 @@
|
||||
/scripts/wechat/wechat-start.sh
|
||||
/scripts/start.sh
|
||||
|
||||
7
root/scripts/start.sh
Executable file
7
root/scripts/start.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# start WeChat application in the background if wechat exists
|
||||
if [ -f /usr/bin/wechat ]; then nohup /usr/bin/wechat > /dev/null 2>&1 & fi
|
||||
|
||||
# start window switcher application in the background
|
||||
nohup sleep 2 && python /scripts/window_switcher.py > /dev/null 2>&1 &
|
||||
383
root/scripts/window_switcher.py
Normal file
383
root/scripts/window_switcher.py
Normal file
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import threading
|
||||
import time
|
||||
from Xlib import X, display
|
||||
from Xlib.ext import shape
|
||||
import re
|
||||
|
||||
class Tooltip:
|
||||
def __init__(self, widget):
|
||||
self.widget = widget
|
||||
self.tipwindow = None
|
||||
self.id = None
|
||||
self.x = self.y = 0
|
||||
|
||||
def showtip(self, text):
|
||||
"Display text in tooltip window"
|
||||
self.text = text
|
||||
if self.tipwindow or not self.text:
|
||||
return
|
||||
x = self.widget.winfo_rootx() + 20
|
||||
y = self.widget.winfo_rooty() + 20
|
||||
self.tipwindow = tw = tk.Toplevel(self.widget)
|
||||
tw.wm_overrideredirect(1)
|
||||
tw.wm_geometry("+%d+%d" % (x, y))
|
||||
label = tk.Label(tw, text=self.text, justify=tk.LEFT,
|
||||
background="#ffffe0", relief=tk.SOLID, borderwidth=1,
|
||||
font=("tahoma", "8", "normal"))
|
||||
label.pack(ipadx=1)
|
||||
|
||||
def hidetip(self):
|
||||
tw = self.tipwindow
|
||||
self.tipwindow = None
|
||||
if tw:
|
||||
tw.destroy()
|
||||
|
||||
def CreateTooltip(widget, text):
|
||||
tooltip = Tooltip(widget)
|
||||
def enter(event):
|
||||
tooltip.showtip(text)
|
||||
def leave(event):
|
||||
tooltip.hidetip()
|
||||
widget.bind('<Enter>', enter)
|
||||
widget.bind('<Leave>', leave)
|
||||
|
||||
class WindowSwitcher:
|
||||
def __init__(self):
|
||||
self.root = tk.Tk()
|
||||
self.root.title("Window Switcher")
|
||||
self.root.overrideredirect(True) # No border
|
||||
self.root.attributes('-topmost', True) # Always on top
|
||||
self.root.configure(bg='#2c2c2c')
|
||||
|
||||
# Set up window style
|
||||
self.setup_ui()
|
||||
|
||||
# Initialize window list
|
||||
self.current_windows = []
|
||||
self.buttons = []
|
||||
|
||||
# Create X11 connection
|
||||
self.d = display.Display()
|
||||
self.root_win = self.d.screen().root
|
||||
|
||||
# Enable event listening (with lower permission mask)
|
||||
self.enable_event_listening()
|
||||
|
||||
# Initial window list
|
||||
self.update_window_list()
|
||||
|
||||
# Bind drag events
|
||||
self.is_dragging = False
|
||||
self.drag_threshold = 5 # Minimum pixel movement to consider as drag
|
||||
self.start_x = 0
|
||||
self.start_y = 0
|
||||
self.drag_start_x = 0
|
||||
self.drag_start_y = 0
|
||||
self.bind_drag_events()
|
||||
|
||||
def setup_ui(self):
|
||||
# Button container
|
||||
self.button_frame = tk.Frame(self.root, bg='#2c2c2c')
|
||||
self.button_frame.pack(fill='both', expand=True, padx=1, pady=1)
|
||||
|
||||
def enable_event_listening(self):
|
||||
"""Enable X11 event listening (with lower permission mask)"""
|
||||
try:
|
||||
# Use PropertyChangeMask and FocusChangeMask
|
||||
self.root_win.change_attributes(
|
||||
event_mask=X.PropertyChangeMask |
|
||||
X.FocusChangeMask
|
||||
)
|
||||
|
||||
# Start event listening thread
|
||||
self.event_thread = threading.Thread(target=self.event_loop, daemon=True)
|
||||
self.event_thread.start()
|
||||
except Exception as e:
|
||||
print(f"Cannot set up event listening: {e}")
|
||||
print("Using polling mode as fallback")
|
||||
# If event listening fails, start polling thread
|
||||
self.polling_thread = threading.Thread(target=self.polling_loop, daemon=True)
|
||||
self.polling_thread.start()
|
||||
|
||||
def bind_drag_events(self):
|
||||
def start_move(event):
|
||||
# Only start dragging if clicked on the background, not on a button
|
||||
if str(event.widget) == str(self.root):
|
||||
self.is_dragging = False # Reset drag state
|
||||
self.start_x = event.x_root
|
||||
self.start_y = event.y_root
|
||||
self.drag_start_x = self.root.winfo_x()
|
||||
self.drag_start_y = self.root.winfo_y()
|
||||
|
||||
def do_move(event):
|
||||
# Calculate distance moved
|
||||
current_x = event.x_root
|
||||
current_y = event.y_root
|
||||
dx = abs(current_x - self.start_x)
|
||||
dy = abs(current_y - self.start_y)
|
||||
|
||||
# If movement exceeds threshold, start dragging
|
||||
if not self.is_dragging and (dx > self.drag_threshold or dy > self.drag_threshold):
|
||||
self.is_dragging = True
|
||||
|
||||
if self.is_dragging:
|
||||
x = self.drag_start_x + (current_x - self.start_x)
|
||||
y = self.drag_start_y + (current_y - self.start_y)
|
||||
self.root.geometry(f"+{x}+{y}")
|
||||
|
||||
def end_move(event):
|
||||
# After drag is complete, reset dragging state
|
||||
self.is_dragging = False
|
||||
|
||||
# Bind events to root window
|
||||
self.root.bind("<Button-1>", start_move)
|
||||
self.root.bind("<B1-Motion>", do_move)
|
||||
self.root.bind("<ButtonRelease-1>", end_move)
|
||||
|
||||
def get_current_desktop_windows(self):
|
||||
"""Get all windows on current desktop (including minimized windows)"""
|
||||
try:
|
||||
# Get current desktop number
|
||||
current_desktop_prop = self.root_win.get_full_property(
|
||||
self.d.intern_atom("_NET_CURRENT_DESKTOP"), X.AnyPropertyType
|
||||
)
|
||||
current_desktop = current_desktop_prop.value[0] if current_desktop_prop else 0
|
||||
|
||||
# Get current active window
|
||||
active_window_prop = self.root_win.get_full_property(
|
||||
self.d.intern_atom("_NET_ACTIVE_WINDOW"), X.AnyPropertyType
|
||||
)
|
||||
active_wid = active_window_prop.value[0] if active_window_prop else None
|
||||
|
||||
# Get all client windows
|
||||
client_list_prop = self.root_win.get_full_property(
|
||||
self.d.intern_atom("_NET_CLIENT_LIST"), X.AnyPropertyType
|
||||
)
|
||||
if not client_list_prop:
|
||||
return []
|
||||
|
||||
window_ids = client_list_prop.value
|
||||
windows = []
|
||||
|
||||
for wid in window_ids:
|
||||
try:
|
||||
win = self.d.create_resource_object('window', wid)
|
||||
|
||||
# Skip current active window
|
||||
if wid == active_wid:
|
||||
continue
|
||||
|
||||
# Get window type, exclude dock, desktop, etc.
|
||||
wtype_prop = win.get_full_property(
|
||||
self.d.intern_atom("_NET_WM_WINDOW_TYPE"), X.AnyPropertyType
|
||||
)
|
||||
if wtype_prop:
|
||||
wtype_atoms = wtype_prop.value
|
||||
skip_types = [
|
||||
self.d.intern_atom("_NET_WM_WINDOW_TYPE_DOCK"),
|
||||
self.d.intern_atom("_NET_WM_WINDOW_TYPE_DESKTOP"),
|
||||
self.d.intern_atom("_NET_WM_WINDOW_TYPE_SPLASH"),
|
||||
self.d.intern_atom("_NET_WM_WINDOW_TYPE_TOOLBAR"),
|
||||
self.d.intern_atom("_NET_WM_WINDOW_TYPE_MENU")
|
||||
]
|
||||
|
||||
if any(atom in wtype_atoms for atom in skip_types):
|
||||
continue
|
||||
|
||||
# Get window's desktop
|
||||
desktop_prop = win.get_full_property(
|
||||
self.d.intern_atom("_NET_WM_DESKTOP"), X.AnyPropertyType
|
||||
)
|
||||
if desktop_prop and desktop_prop.value[0] != current_desktop:
|
||||
continue
|
||||
|
||||
# Get window name
|
||||
name_prop = win.get_full_property(
|
||||
self.d.intern_atom("_NET_WM_NAME"), X.AnyPropertyType
|
||||
) or win.get_full_property(
|
||||
self.d.intern_atom("WM_NAME"), X.AnyPropertyType
|
||||
)
|
||||
|
||||
title = name_prop.value.decode() if name_prop and name_prop.value else "Unknown"
|
||||
|
||||
# Check if window is minimized
|
||||
state_prop = win.get_full_property(
|
||||
self.d.intern_atom("_NET_WM_STATE"), X.AnyPropertyType
|
||||
)
|
||||
is_minimized = False
|
||||
if state_prop:
|
||||
state_atoms = state_prop.value
|
||||
minimized_atom = self.d.intern_atom("_NET_WM_STATE_HIDDEN")
|
||||
if minimized_atom in state_atoms:
|
||||
is_minimized = True
|
||||
|
||||
# Show non-active windows (including minimized windows)
|
||||
windows.append((wid, title, is_minimized))
|
||||
except Exception as e:
|
||||
print(f"Error processing window {wid}: {e}")
|
||||
continue
|
||||
|
||||
# Return at most 10 windows
|
||||
return windows[:10]
|
||||
except Exception as e:
|
||||
print(f"Error getting window list: {e}")
|
||||
return []
|
||||
|
||||
def update_window_list(self):
|
||||
"""Update window list"""
|
||||
new_windows = self.get_current_desktop_windows()
|
||||
if new_windows != self.current_windows:
|
||||
self.current_windows = new_windows
|
||||
# Update UI in main thread
|
||||
self.root.after(0, self.refresh_ui)
|
||||
|
||||
def activate_window(self, wid):
|
||||
"""Activate specified window only if not dragging"""
|
||||
# Activate only if not dragging
|
||||
if not self.is_dragging:
|
||||
try:
|
||||
win = self.d.create_resource_object('window', wid)
|
||||
|
||||
# Build ClientMessage event
|
||||
from Xlib.protocol import event
|
||||
atom = self.d.intern_atom("_NET_ACTIVE_WINDOW")
|
||||
|
||||
# Create ClientMessage event
|
||||
msg = event.ClientMessage(
|
||||
window=win,
|
||||
client_type=atom,
|
||||
data=(32, [1, X.CurrentTime, 0, 0, 0])
|
||||
)
|
||||
|
||||
# Send activate window message
|
||||
self.root_win.send_event(
|
||||
msg,
|
||||
event_mask=X.SubstructureRedirectMask | X.SubstructureNotifyMask
|
||||
)
|
||||
self.d.flush()
|
||||
|
||||
# Bring window to top
|
||||
win.configure(stack_mode=X.Above)
|
||||
self.root.attributes('-topmost', True) # Keep switcher on top
|
||||
except Exception as e:
|
||||
print(f"Error activating window: {e}")
|
||||
|
||||
def get_display_text(self, title):
|
||||
"""Determine display text based on character type"""
|
||||
if not title:
|
||||
return "?"
|
||||
|
||||
# Check if it contains Chinese characters
|
||||
has_chinese = bool(re.search(r'[\u4e00-\u9fff]', title))
|
||||
|
||||
if has_chinese:
|
||||
# If contains Chinese, show first character only
|
||||
return title[0]
|
||||
else:
|
||||
# If only English, show first 2-3 characters, but not exceeding title length
|
||||
return title[:3]
|
||||
|
||||
def refresh_ui(self):
|
||||
"""Refresh UI, update window list"""
|
||||
# Clear old buttons
|
||||
for btn in self.buttons:
|
||||
btn.destroy()
|
||||
self.buttons.clear()
|
||||
|
||||
# Adjust window size based on number of windows
|
||||
num_windows = len(self.current_windows)
|
||||
if num_windows == 0:
|
||||
# If no windows, hide entire window
|
||||
self.root.withdraw()
|
||||
else:
|
||||
# Calculate window width (each cell 30px, plus borders and spacing)
|
||||
width = num_windows * 30 + (num_windows - 1) * 2 # 30px cell + 2px border
|
||||
self.root.geometry(f"{width}x30")
|
||||
self.root.deiconify() # Ensure window is visible
|
||||
|
||||
# Create new buttons
|
||||
for wid, title, is_minimized in self.current_windows:
|
||||
# Get text to display
|
||||
display_text = self.get_display_text(title)
|
||||
|
||||
# Create fixed-size frame container
|
||||
container = tk.Frame(self.button_frame, width=30, height=30, bg='#4a4a4a')
|
||||
container.pack_propagate(False) # Prevent container size from changing with content
|
||||
container.pack(side='left', padx=1, pady=1)
|
||||
|
||||
# Create button inside container
|
||||
btn = tk.Button(
|
||||
container,
|
||||
text=display_text,
|
||||
command=lambda w=wid: self.activate_window(w),
|
||||
bg='#4a4a4a',
|
||||
fg='#ffffff',
|
||||
activebackground='#5a5a5a',
|
||||
activeforeground='#ffffff',
|
||||
relief='flat',
|
||||
font=('Arial', 10, 'bold'),
|
||||
bd=0 # No border
|
||||
)
|
||||
|
||||
# Add tooltip
|
||||
CreateTooltip(btn, f"{title} {'(Minimized)' if is_minimized else ''}")
|
||||
|
||||
# Fill container with button
|
||||
btn.pack(fill='both', expand=True)
|
||||
|
||||
self.buttons.append(container)
|
||||
|
||||
def event_loop(self):
|
||||
"""Event listening loop"""
|
||||
# Event mask constants
|
||||
PROPERTY_CHANGE = 28 # X.PropertyChangeMask
|
||||
FOCUS_IN = 9 # X.FocusIn
|
||||
FOCUS_OUT = 10 # X.FocusOut
|
||||
|
||||
while True:
|
||||
try:
|
||||
event = self.d.next_event()
|
||||
|
||||
# Check if window list update is needed
|
||||
should_update = False
|
||||
|
||||
# Compare using event type values
|
||||
if event.type == PROPERTY_CHANGE:
|
||||
# Window property changed, check if it's window state or title change
|
||||
atom_name = self.d.get_atom_name(event.atom)
|
||||
if atom_name in ["_NET_WM_NAME", "WM_NAME", "_NET_WM_STATE",
|
||||
"_NET_ACTIVE_WINDOW", "_NET_CLIENT_LIST"]:
|
||||
should_update = True
|
||||
elif event.type == FOCUS_IN or event.type == FOCUS_OUT:
|
||||
# Focus changed
|
||||
should_update = True
|
||||
|
||||
if should_update:
|
||||
# Add delay to prevent continuous triggering
|
||||
time.sleep(0.1)
|
||||
self.update_window_list()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Event loop error: {e}")
|
||||
time.sleep(0.1) # Delay on error
|
||||
|
||||
def polling_loop(self):
|
||||
"""Polling loop (fallback)"""
|
||||
while True:
|
||||
try:
|
||||
self.update_window_list()
|
||||
time.sleep(2) # Poll every 2 seconds
|
||||
except Exception as e:
|
||||
print(f"Polling loop error: {e}")
|
||||
time.sleep(2)
|
||||
|
||||
def run(self):
|
||||
self.root.mainloop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = WindowSwitcher()
|
||||
app.run()
|
||||
Reference in New Issue
Block a user