# Переименование окон 1С
Приложение на Python, которое находится в системном трее и:
- сразу после запуска сканирует открытые окна;
- периодически повторяет сканирование;
- обрабатывает только окна процессов, имя которых содержит `1c`;
- определяет имя информационной базы по параметрам `/IBName`, `/IBConnectionString`, `/F` и `/S`;
- меняет заголовок по схеме `Имя базы — прежний заголовок`;
- не добавляет имя повторно, если такой префикс уже установлен;
- при штатном выходе восстанавливает прежние заголовки всех ещё открытых окон, переименованных текущим запуском;
- позволяет вручную запустить сканирование через меню значка в трее;
- позволяет выбрать готовый интервал сканирования или указать собственный в секундах;
- не допускает запуск второго экземпляра приложения;
- использует узнаваемую жёлто-красно-чёрную иконку 1С в трее и у собранного EXE.
## Запуск готового приложения
Приложение находится в папке dist
## Запуск скриптом без компиляции, используя python
1. Установите Python 3.11 или новее.
2. Запустите `install_and_run.bat`.
3. Значок `1C` появится в области уведомлений Windows.
Приложение запускается через `pythonw`, поэтому отдельное консольное окно не появляется.
## Сборка приложения
Запустите `build_exe.bat`.
При сборке файлы `1c_icon.png` и `1c_icon.ico` автоматически используются для значка в трее и значка EXE.
Результат появится здесь:
dist\1C-Window-Renamer.exe
## Меню в трее
- **Переименовать сейчас** — немедленно просканировать окна.
- **Интервал сканирования** — выбрать 5, 10, 30 секунд, 1 или 5 минут либо открыть пункт **Задать вручную…** и ввести значение от 1 до 86 400 секунд. Значение в поле можно полностью стереть или заменить; `Enter` подтверждает ввод, `Esc` отменяет.
- **Выход** — остановить сканирование, вернуть исходные заголовки переименованным окнам и завершить приложение.
По умолчанию используется интервал 10 секунд. Выбранное значение сохраняется между запусками в пользовательской папке `%APPDATA%\1CWindowRenamer`.
## Восстановление заголовков при выходе
Приложение запоминает исходный заголовок каждого окна, которое оно успешно переименовало. При выборе пункта **Выход** периодическое сканирование останавливается, открытым окнам возвращаются прежние заголовки, после чего приложение завершается.
Восстановление возможно только при штатном завершении приложения. При принудительном завершении процесса через Диспетчер задач код очистки выполнить невозможно.
## Как определяется имя базы
Ключи проверяются в таком порядке:
1. `/IBName` — используется указанное имя базы.
2. `/IBConnectionString` — для серверной базы берётся `Ref`, для файловой — имя последней папки из `File`.
3. `/F` — берётся имя последней папки файловой базы.
4. `/S` — берётся имя базы из строки `сервер\база`.
Если определить имя не удалось, окно не переименовывается.
## Настройки в коде
В начале файла `rename_1c_windows.py` доступны параметры:
DEFAULT_SCAN_INTERVAL_SECONDS = 10
AVAILABLE_SCAN_INTERVALS = (5, 10, 30, 60, 300)
MIN_SCAN_INTERVAL_SECONDS = 1
MAX_SCAN_INTERVAL_SECONDS = 86_400
PROCESS_NAME_FRAGMENT = "1c"
TITLE_SEPARATOR = " — "
RENAME_OWNED_WINDOWS = False
При `RENAME_OWNED_WINDOWS = False` переименовываются только основные окна. Чтобы также менять заголовки принадлежащих процессу диалогов 1С, установите `True`.
## Права доступа
Если 1С запущена с повышенными правами, приложение также следует запускать от администратора. Иначе Windows может запретить чтение параметров процесса или изменение заголовка окна.
## Код отдельно, доступный для копирования
from __future__ import annotations
import ctypes
import json
import os
import re
import sys
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional
import psutil
import pystray
import win32con
import win32gui
import win32process
from PIL import Image, ImageDraw
# ----------------------------- Настройки -----------------------------------
DEFAULT_SCAN_INTERVAL_SECONDS = 10
AVAILABLE_SCAN_INTERVALS = (5, 10, 30, 60, 300)
MIN_SCAN_INTERVAL_SECONDS = 1
MAX_SCAN_INTERVAL_SECONDS = 86_400
PROCESS_NAME_FRAGMENT = "1c"
TITLE_SEPARATOR = " — "
# False: переименовывать только основные окна 1С.
# True: также переименовывать диалоги и другие принадлежащие процессу окна.
RENAME_OWNED_WINDOWS = False
APP_NAME = "1C Window Renamer"
ICON_FILENAME = "1c_icon.png"
MUTEX_NAME = r"Local\OneCWindowRenamer"
# -------------------------- Хранение настроек ------------------------------
def settings_file_path() -> Path:
app_data = os.environ.get("APPDATA")
if app_data:
settings_directory = Path(app_data) / "1CWindowRenamer"
else:
settings_directory = Path.home() / ".1c_window_renamer"
return settings_directory / "settings.json"
def load_scan_interval() -> int:
try:
data = json.loads(settings_file_path().read_text(encoding="utf-8"))
interval = int(data.get("scan_interval_seconds", DEFAULT_SCAN_INTERVAL_SECONDS))
if MIN_SCAN_INTERVAL_SECONDS <= interval <= MAX_SCAN_INTERVAL_SECONDS:
return interval
except (OSError, ValueError, TypeError, json.JSONDecodeError):
pass
return DEFAULT_SCAN_INTERVAL_SECONDS
def save_scan_interval(interval: int) -> None:
if not MIN_SCAN_INTERVAL_SECONDS <= interval <= MAX_SCAN_INTERVAL_SECONDS:
return
try:
path = settings_file_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{"scan_interval_seconds": interval},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
except OSError:
# Настройка продолжит действовать до завершения текущего запуска.
pass
# --------------------------- Определение базы -------------------------------
@dataclass(frozen=True)
class ProcessInfo:
process_name: str
base_name: Optional[str]
@dataclass(frozen=True)
class RenamedWindowInfo:
process_id: int
prefix: str
original_title: str
renamed_title: str
def clean_value(value: str) -> str:
"""Убирает внешние кавычки и лишние пробелы из значения параметра."""
value = value.strip()
while len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
value = value[1:-1].strip()
return value
def get_switch_value(arguments: list[str], switch: str) -> Optional[str]:
"""
Находит параметр командной строки в вариантах:
/IBName "Имя"
/IBName"Имя"
/IBName=Имя
"""
switch_lower = switch.lower()
for index, raw_argument in enumerate(arguments):
argument = raw_argument.strip()
argument_lower = argument.lower()
if argument_lower == switch_lower:
if index + 1 < len(arguments):
value = clean_value(arguments[index + 1])
return value or None
return None
if not argument_lower.startswith(switch_lower):
continue
remainder = argument[len(switch):].strip()
if not remainder:
continue
# Не считаем /SomeKey значением короткого ключа /S или /F.
# При этом поддерживаем штатные формы /Sserver\base и /FC:\Base.
if remainder[0] not in {'=', '"', "'"} and switch_lower in {"/s", "/f"}:
if not any(separator in remainder for separator in ("\\", "/", ":")):
continue
if remainder.startswith("="):
remainder = remainder[1:].strip()
value = clean_value(remainder)
if value:
return value
return None
def base_name_from_file_path(value: str) -> Optional[str]:
"""Получает имя файловой базы из пути /F или File=... ."""
value = clean_value(value).rstrip("\\/")
if not value:
return None
normalized = value.replace("/", "\\")
base_name = normalized.rsplit("\\", 1)[-1].strip()
return base_name or value
def base_name_from_server_path(value: str) -> Optional[str]:
"""Получает имя серверной базы из значения /S сервер\база."""
value = clean_value(value).strip().rstrip("\\/")
if not value:
return None
normalized = value.replace("/", "\\")
base_name = normalized.rsplit("\\", 1)[-1].strip()
return base_name or value
def connection_string_parameter(connection_string: str, parameter: str) -> Optional[str]:
"""Извлекает параметр из строки соединения 1С без учёта регистра."""
pattern = re.compile(
rf"(?:^|;)\s*{re.escape(parameter)}\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|([^;]*))",
re.IGNORECASE,
)
match = pattern.search(clean_value(connection_string))
if not match:
return None
value = next((group for group in match.groups() if group is not None), "")
value = clean_value(value)
return value or None
def base_name_from_connection_string(connection_string: str) -> Optional[str]:
"""Определяет базу из /IBConnectionString."""
reference = connection_string_parameter(connection_string, "Ref")
if reference:
return reference
file_path = connection_string_parameter(connection_string, "File")
if file_path:
return base_name_from_file_path(file_path)
return None
def detect_base_name(command_line: Iterable[str]) -> Optional[str]:
"""
Определяет имя базы по четырём вариантам запуска 1С.
Приоритет:
1. /IBName
2. /IBConnectionString
3. /F
4. /S
"""
arguments = [str(argument) for argument in command_line]
ib_name = get_switch_value(arguments, "/IBName")
if ib_name:
return ib_name
connection_string = get_switch_value(arguments, "/IBConnectionString")
if connection_string:
base_name = base_name_from_connection_string(connection_string)
if base_name:
return base_name
file_path = get_switch_value(arguments, "/F")
if file_path:
return base_name_from_file_path(file_path)
server_path = get_switch_value(arguments, "/S")
if server_path:
return base_name_from_server_path(server_path)
return None
# ------------------------- Работа с окнами Windows --------------------------
class WindowRenamer:
def __init__(self) -> None:
self._scan_lock = threading.Lock()
self._process_cache: dict[int, Optional[ProcessInfo]] = {}
self._renamed_windows: dict[int, RenamedWindowInfo] = {}
def scan_and_rename(self) -> int:
"""Сканирует открытые окна и возвращает число переименованных окон."""
if not self._scan_lock.acquire(blocking=False):
return 0
try:
self._process_cache.clear()
self._remove_closed_tracked_windows()
renamed_count = 0
def enum_callback(hwnd: int, _: object) -> bool:
nonlocal renamed_count
try:
if self._rename_window_if_needed(hwnd):
renamed_count += 1
except Exception:
# Ошибка одного окна не должна прерывать общее сканирование.
pass
return True
win32gui.EnumWindows(enum_callback, None)
return renamed_count
except Exception:
return 0
finally:
self._scan_lock.release()
def _rename_window_if_needed(self, hwnd: int) -> bool:
if not win32gui.IsWindow(hwnd) or not win32gui.IsWindowVisible(hwnd):
return False
if not RENAME_OWNED_WINDOWS and win32gui.GetWindow(hwnd, win32con.GW_OWNER):
return False
old_title = win32gui.GetWindowText(hwnd).strip()
if not old_title:
return False
_, process_id = win32process.GetWindowThreadProcessId(hwnd)
process_info = self._get_process_info(process_id)
if process_info is None or not process_info.base_name:
return False
prefix = f"{process_info.base_name}{TITLE_SEPARATOR}"
if old_title.startswith(prefix):
self._refresh_tracked_title(hwnd, process_id, prefix, old_title)
return False
new_title = f"{prefix}{old_title}"
win32gui.SetWindowText(hwnd, new_title)
if win32gui.GetWindowText(hwnd) != new_title:
return False
self._renamed_windows[hwnd] = RenamedWindowInfo(
process_id=process_id,
prefix=prefix,
original_title=old_title,
renamed_title=new_title,
)
return True
def restore_original_titles(self) -> int:
"""Возвращает исходные заголовки окнам, переименованным этим запуском."""
restored_count = 0
with self._scan_lock:
tracked_windows = list(self._renamed_windows.items())
self._renamed_windows.clear()
for hwnd, window_info in tracked_windows:
try:
if not win32gui.IsWindow(hwnd):
continue
_, process_id = win32process.GetWindowThreadProcessId(hwnd)
if process_id != window_info.process_id:
# HWND мог быть повторно использован другим процессом.
continue
current_title = win32gui.GetWindowText(hwnd)
if current_title == window_info.renamed_title:
restored_title = window_info.original_title
elif current_title.startswith(window_info.prefix):
# Если 1С успела изменить часть заголовка после префикса,
# удаляем только добавленный приложением префикс.
restored_title = current_title[len(window_info.prefix):]
else:
# Заголовок уже изменён сторонним кодом или самой 1С.
continue
if not restored_title:
continue
win32gui.SetWindowText(hwnd, restored_title)
if win32gui.GetWindowText(hwnd) == restored_title:
restored_count += 1
except Exception:
# Ошибка одного окна не должна мешать восстановлению остальных.
pass
return restored_count
def _refresh_tracked_title(
self,
hwnd: int,
process_id: int,
prefix: str,
current_title: str,
) -> None:
"""Обновляет сохранённый заголовок, если 1С изменила его после префикса."""
tracked = self._renamed_windows.get(hwnd)
if tracked is None or tracked.process_id != process_id:
return
original_title = current_title[len(prefix):]
if not original_title:
return
self._renamed_windows[hwnd] = RenamedWindowInfo(
process_id=process_id,
prefix=prefix,
original_title=original_title,
renamed_title=current_title,
)
def _remove_closed_tracked_windows(self) -> None:
"""Удаляет записи о закрытых окнах и повторно использованных HWND."""
for hwnd, window_info in list(self._renamed_windows.items()):
try:
if not win32gui.IsWindow(hwnd):
del self._renamed_windows[hwnd]
continue
_, process_id = win32process.GetWindowThreadProcessId(hwnd)
if process_id != window_info.process_id:
del self._renamed_windows[hwnd]
except Exception:
self._renamed_windows.pop(hwnd, None)
def _get_process_info(self, process_id: int) -> Optional[ProcessInfo]:
if process_id in self._process_cache:
return self._process_cache[process_id]
result: Optional[ProcessInfo] = None
try:
process = psutil.Process(process_id)
with process.oneshot():
process_name = process.name()
if PROCESS_NAME_FRAGMENT.lower() not in process_name.lower():
self._process_cache[process_id] = None
return None
command_line = process.cmdline()
result = ProcessInfo(
process_name=process_name,
base_name=detect_base_name(command_line),
)
except (psutil.NoSuchProcess, psutil.ZombieProcess, psutil.AccessDenied):
result = None
except Exception:
result = None
self._process_cache[process_id] = result
return result
# ------------------------------ Ресурсы -------------------------------------
def application_resource_path(file_name: str) -> Path:
"""Возвращает путь к ресурсу при запуске из исходников и из PyInstaller EXE."""
bundle_directory = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
return bundle_directory / file_name
# ------------------------------ Трей ----------------------------------------
class TrayApplication:
def __init__(self) -> None:
self._stop_event = threading.Event()
self._scanner_wakeup_event = threading.Event()
self._interval_lock = threading.Lock()
self._shutdown_lock = threading.Lock()
self._custom_interval_dialog_lock = threading.Lock()
self._shutdown_complete = False
self._scan_interval = load_scan_interval()
self._renamer = WindowRenamer()
self._icon = pystray.Icon(
name="one_c_window_renamer",
icon=self._create_icon(),
title="Переименование окон 1С",
menu=self._create_menu(),
)
self._worker = threading.Thread(
target=self._scanner_loop,
name="window-scanner",
daemon=True,
)
def _create_menu(self) -> pystray.Menu:
interval_items = []
for interval in AVAILABLE_SCAN_INTERVALS:
interval_items.append(
pystray.MenuItem(
self._format_interval(interval),
self._make_interval_handler(interval),
checked=self._make_interval_checker(interval),
radio=True,
)
)
interval_items.extend(
(
pystray.Menu.SEPARATOR,
pystray.MenuItem(
"Задать вручную…",
self._set_custom_interval,
),
)
)
return pystray.Menu(
pystray.MenuItem(
"Переименовать сейчас",
self._scan_now,
default=True,
),
pystray.MenuItem(
"Интервал сканирования",
pystray.Menu(*interval_items),
),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Выход", self._exit),
)
def run(self) -> None:
try:
self._worker.start()
self._icon.run()
finally:
self._shutdown()
def _scanner_loop(self) -> None:
# Первое сканирование выполняется сразу после запуска.
while not self._stop_event.is_set():
self._renamer.scan_and_rename()
self._scanner_wakeup_event.clear()
interval = self._get_scan_interval()
self._scanner_wakeup_event.wait(interval)
def _get_scan_interval(self) -> int:
with self._interval_lock:
return self._scan_interval
def _set_scan_interval(self, interval: int) -> None:
if not MIN_SCAN_INTERVAL_SECONDS <= interval <= MAX_SCAN_INTERVAL_SECONDS:
return
with self._interval_lock:
self._scan_interval = interval
save_scan_interval(interval)
self._scanner_wakeup_event.set()
self._icon.update_menu()
def _set_custom_interval(
self,
_icon: pystray.Icon,
_item: pystray.MenuItem,
) -> None:
# Окно Tk запускается в отдельном UI-потоке. Так оно не конфликтует
# с циклом обработки сообщений значка pystray и нормально принимает ввод.
threading.Thread(
target=self._show_custom_interval_dialog,
name="custom-interval-dialog",
daemon=True,
).start()
def _show_custom_interval_dialog(self) -> None:
if not self._custom_interval_dialog_lock.acquire(blocking=False):
return
root = None
try:
import tkinter as tk
from tkinter import messagebox
root = tk.Tk()
root.title("Интервал сканирования")
root.resizable(False, False)
root.attributes("-topmost", True)
container = tk.Frame(root, padx=14, pady=12)
container.pack(fill="both", expand=True)
label = tk.Label(
container,
text="Введите интервал сканирования в секундах:",
anchor="w",
)
label.pack(fill="x")
interval_value = tk.StringVar(value=str(self._get_scan_interval()))
entry = tk.Entry(
container,
textvariable=interval_value,
width=32,
state="normal",
)
entry.pack(fill="x", pady=(5, 12))
buttons = tk.Frame(container)
buttons.pack(fill="x")
def close_dialog() -> None:
root.quit()
def apply_interval() -> None:
raw_value = interval_value.get().strip()
try:
interval = int(raw_value)
except ValueError:
messagebox.showerror(
"Некорректное значение",
"Введите целое число секунд.",
parent=root,
)
entry.focus_force()
entry.selection_range(0, tk.END)
return
if not MIN_SCAN_INTERVAL_SECONDS <= interval <= MAX_SCAN_INTERVAL_SECONDS:
messagebox.showerror(
"Некорректное значение",
(
"Введите значение от "
f"{MIN_SCAN_INTERVAL_SECONDS} до "
f"{MAX_SCAN_INTERVAL_SECONDS} секунд."
),
parent=root,
)
entry.focus_force()
entry.selection_range(0, tk.END)
return
self._set_scan_interval(interval)
close_dialog()
ok_button = tk.Button(
buttons,
text="OK",
width=12,
command=apply_interval,
default="active",
)
ok_button.pack(side="left", padx=(38, 8))
cancel_button = tk.Button(
buttons,
text="Отмена",
width=12,
command=close_dialog,
)
cancel_button.pack(side="left")
root.bind("<Return>", lambda _event: apply_interval())
root.bind("<Escape>", lambda _event: close_dialog())
root.protocol("WM_DELETE_WINDOW", close_dialog)
root.update_idletasks()
window_width = root.winfo_reqwidth()
window_height = root.winfo_reqheight()
screen_width = root.winfo_screenwidth()
screen_height = root.winfo_screenheight()
position_x = max(0, (screen_width - window_width) // 2)
position_y = max(0, (screen_height - window_height) // 2)
root.geometry(f"+{position_x}+{position_y}")
def activate_entry() -> None:
root.lift()
root.focus_force()
entry.configure(state="normal")
entry.focus_force()
entry.selection_range(0, tk.END)
entry.icursor(tk.END)
root.after(100, activate_entry)
root.mainloop()
except Exception as error:
ctypes.windll.user32.MessageBoxW(
None,
f"Не удалось открыть окно ввода интервала:\n{error}",
"Переименование окон 1С",
0x10,
)
finally:
if root is not None:
try:
root.destroy()
except Exception:
pass
self._custom_interval_dialog_lock.release()
def _make_interval_handler(self, interval: int):
def handler(_icon: pystray.Icon, _item: pystray.MenuItem) -> None:
self._set_scan_interval(interval)
return handler
def _make_interval_checker(self, interval: int):
def checker(_item: pystray.MenuItem) -> bool:
return self._get_scan_interval() == interval
return checker
def _scan_now(self, _icon: pystray.Icon, _item: pystray.MenuItem) -> None:
threading.Thread(
target=self._renamer.scan_and_rename,
name="manual-window-scan",
daemon=True,
).start()
def _exit(self, icon: pystray.Icon, _item: pystray.MenuItem) -> None:
self._stop_event.set()
self._scanner_wakeup_event.set()
icon.stop()
def _shutdown(self) -> None:
"""Останавливает сканирование и восстанавливает изменённые заголовки."""
with self._shutdown_lock:
if self._shutdown_complete:
return
self._stop_event.set()
self._scanner_wakeup_event.set()
if self._worker.is_alive() and threading.current_thread() is not self._worker:
self._worker.join()
self._renamer.restore_original_titles()
self._shutdown_complete = True
@staticmethod
def _format_interval(interval: int) -> str:
if interval < 60:
return f"{interval} сек."
if interval == 60:
return "1 мин."
return f"{interval // 60} мин."
@staticmethod
def _create_icon() -> Image.Image:
icon_path = application_resource_path(ICON_FILENAME)
try:
with Image.open(icon_path) as source_image:
return source_image.convert("RGBA").resize((64, 64), Image.Resampling.LANCZOS)
except (OSError, ValueError):
# Резервная иконка на случай повреждения или отсутствия ресурса.
size = 64
image = Image.new("RGBA", (size, size), (255, 221, 0, 255))
draw = ImageDraw.Draw(image)
draw.rounded_rectangle(
(2, 2, size - 3, size - 3),
radius=8,
outline=(30, 30, 30, 255),
width=3,
)
draw.text((10, 18), "1", fill=(226, 20, 38, 255), stroke_width=1)
draw.text((33, 18), "C", fill=(20, 20, 20, 255), stroke_width=1)
return image
# -------------------------- Один экземпляр ---------------------------------
_mutex_handle: Optional[int] = None
def acquire_single_instance_mutex() -> bool:
global _mutex_handle
kernel32 = ctypes.windll.kernel32
kernel32.CreateMutexW.argtypes = [ctypes.c_void_p, ctypes.c_bool, ctypes.c_wchar_p]
kernel32.CreateMutexW.restype = ctypes.c_void_p
_mutex_handle = kernel32.CreateMutexW(None, False, MUTEX_NAME)
already_exists = kernel32.GetLastError() == 183 # ERROR_ALREADY_EXISTS
if already_exists:
ctypes.windll.user32.MessageBoxW(
None,
"Приложение уже запущено и находится в системном трее.",
"Переименование окон 1С",
0x40,
)
return False
return True
def main() -> None:
if not acquire_single_instance_mutex():
return
try:
TrayApplication().run()
except Exception as error:
ctypes.windll.user32.MessageBoxW(
None,
f"Приложение завершено из-за ошибки:\n{error}",
"Переименование окон 1С",
0x10,
)
if __name__ == "__main__":
main()
Вступайте в нашу телеграмм-группу Инфостарт
