-
Notifications
You must be signed in to change notification settings - Fork 33
Expand file tree
/
Copy pathwindowsIntegration.py
More file actions
231 lines (187 loc) · 9.52 KB
/
Copy pathwindowsIntegration.py
File metadata and controls
231 lines (187 loc) · 9.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
"""Optional Windows conveniences for the standalone cewe2pdf executable.
This module is deliberately independent of the renderer. Importing it on
Linux or macOS is harmless; its Windows-only operations simply report that
they are unavailable.
"""
import ctypes
import os
from pathlib import Path, PureWindowsPath
import re
import shutil
import sys
from typing import Iterable
APPLICATION_NAME = 'cewe2pdf'
EXPLORER_MENU_TEXT = 'Create PDF with cewe2pdf'
FILE_EXTENSIONS = ('.mcf', '.mcfx')
INSTALL_REGISTRY_KEY = r'Software\cewe2pdf'
def isWindowsFrozenExecutable() -> bool:
"""Return whether this is the Windows executable made by PyInstaller."""
return os.name == 'nt' and getattr(sys, 'frozen', False)
def isCeweInstallationFolder(folder: str | Path) -> bool:
"""Return whether *folder* has the CEWE resources required by cewe2pdf."""
folderPath = Path(folder)
return (
folderPath.is_dir()
and (folderPath / 'Resources' / 'config' / 'keyaccount.xml').is_file()
and (folderPath / 'Resources' / 'photofun' / 'fonts').is_dir())
def _registryInstallationFolders() -> tuple[Path, ...]:
"""Return possible application roots from standard Windows uninstall keys."""
if os.name != 'nt':
return tuple()
import winreg # pylint: disable=import-outside-toplevel
uninstallKeys = (
r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
r'SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall')
candidates: list[Path] = []
for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):
for uninstallKey in uninstallKeys:
try:
with winreg.OpenKey(hive, uninstallKey) as key:
subkeyCount = winreg.QueryInfoKey(key)[0]
subkeyNames = (winreg.EnumKey(key, index) for index in range(subkeyCount))
for subkeyName in subkeyNames:
try:
with winreg.OpenKey(key, subkeyName) as subkey:
installLocation, _ = winreg.QueryValueEx(subkey, 'InstallLocation')
if installLocation:
candidates.append(Path(installLocation))
except OSError:
continue
except OSError:
continue
return tuple(candidates)
def _executableFolderFromCommand(command: str | None) -> str | None:
"""Extract an executable's parent folder from a Windows open command."""
if not command:
return None
# Registered commands normally quote an executable path because Program
# Files contains a space. Also accept an unusual unquoted path without
# spaces; anything more elaborate is left to the uninstall-key fallback.
match = re.match(r'^\s*"([^\"]+\.exe)"|^\s*([^\s]+\.exe)', command, re.IGNORECASE)
if match is None:
return None
# The registry command always uses Windows path syntax, including when
# this parser is exercised by the Linux CI test run.
return str(PureWindowsPath(match.group(1) or match.group(2)).parent)
def _readRegistryValue(hive, keyName: str, valueName: str = '') -> str | None:
"""Return a registry string value, treating a missing value as normal."""
import winreg # pylint: disable=import-outside-toplevel
try:
with winreg.OpenKey(hive, keyName) as key:
value, _ = winreg.QueryValueEx(key, valueName)
return value if isinstance(value, str) else None
except OSError:
return None
def _associatedCeweFolders() -> tuple[str, ...]:
"""Return folders used by Windows to open MCF and MCFX files.
The default program is the best evidence for a CEWE installation, and
works equally for CEWE's retailer-branded editions. UserChoice is checked
first because Windows may override the normal extension association there.
"""
if os.name != 'nt':
return tuple()
import winreg # pylint: disable=import-outside-toplevel
candidates: list[str] = []
for extension in FILE_EXTENSIONS:
progIds = []
userChoice = _readRegistryValue(
winreg.HKEY_CURRENT_USER,
fr'Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\{extension}\UserChoice',
'ProgId')
association = _readRegistryValue(winreg.HKEY_CLASSES_ROOT, extension)
for progId in (userChoice, association):
if progId and progId not in progIds:
progIds.append(progId)
for progId in progIds:
command = _readRegistryValue(
winreg.HKEY_CLASSES_ROOT, fr'{progId}\shell\open\command')
executableFolder = _executableFolderFromCommand(command)
if executableFolder is not None:
candidates.append(executableFolder)
return tuple(candidates)
def findInstalledCeweFolder(candidateFolders: Iterable[str | Path] | None = None) -> str | None:
"""Find a valid local CEWE application folder, or return ``None``.
The optional candidates argument makes the validation logic easy to test
without Windows registry access.
"""
candidates = tuple(candidateFolders) if candidateFolders is not None else (
_associatedCeweFolders() + _registryInstallationFolders())
for candidate in candidates:
if isCeweInstallationFolder(candidate):
return str(Path(candidate))
return None
def _notifyExplorerOfAssociationChange() -> None:
"""Ask Explorer to refresh file-association menus after a registry change."""
if os.name == 'nt':
# SHCNE_ASSOCCHANGED, SHCNF_IDLIST, ignored item pointers.
ctypes.windll.shell32.SHChangeNotify(0x08000000, 0, None, None)
def _setRegistryDefault(key, value: str) -> None:
import winreg # pylint: disable=import-outside-toplevel
winreg.SetValueEx(key, '', 0, winreg.REG_SZ, value)
def _installExplorerVerb(executablePath: Path, extension: str) -> None:
import winreg # pylint: disable=import-outside-toplevel
verbKeyName = fr'Software\Classes\SystemFileAssociations\{extension}\shell\{APPLICATION_NAME}'
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, verbKeyName) as verbKey:
winreg.SetValueEx(verbKey, 'MUIVerb', 0, winreg.REG_SZ, EXPLORER_MENU_TEXT)
winreg.SetValueEx(verbKey, 'Icon', 0, winreg.REG_SZ, f'{executablePath},0')
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, verbKeyName + r'\command') as commandKey:
_setRegistryDefault(commandKey, f'"{executablePath}" --automatic "%1"')
def installWindowsIntegration(sourceExecutable: str | Path | None = None) -> Path:
"""Copy the frozen executable locally and install per-user Explorer menus."""
if not isWindowsFrozenExecutable():
raise RuntimeError('Windows integration is available only from the frozen Windows executable.')
sourcePath = Path(sourceExecutable or sys.executable).resolve()
localAppData = Path(os.environ.get('LOCALAPPDATA', Path.home() / 'AppData' / 'Local'))
installFolder = localAppData / APPLICATION_NAME
installFolder.mkdir(parents=True, exist_ok=True)
installedExecutable = installFolder / sourcePath.name
if sourcePath != installedExecutable:
shutil.copy2(sourcePath, installedExecutable)
import winreg # pylint: disable=import-outside-toplevel
for extension in FILE_EXTENSIONS:
_installExplorerVerb(installedExecutable, extension)
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, INSTALL_REGISTRY_KEY) as installationKey:
winreg.SetValueEx(installationKey, 'InstallPath', 0, winreg.REG_SZ, str(installedExecutable))
_notifyExplorerOfAssociationChange()
return installedExecutable
def uninstallWindowsIntegration() -> str | None:
"""Remove the per-user Explorer menus, leaving the installed EXE in place."""
if os.name != 'nt':
raise RuntimeError('Windows integration is available only on Windows.')
import winreg # pylint: disable=import-outside-toplevel
installedPath = None
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, INSTALL_REGISTRY_KEY) as installationKey:
installedPath, _ = winreg.QueryValueEx(installationKey, 'InstallPath')
except OSError:
pass
for extension in FILE_EXTENSIONS:
verbKeyName = fr'Software\Classes\SystemFileAssociations\{extension}\shell\{APPLICATION_NAME}'
try:
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, verbKeyName + r'\command')
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, verbKeyName)
except OSError:
continue
try:
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, INSTALL_REGISTRY_KEY)
except OSError:
pass
_notifyExplorerOfAssociationChange()
return installedPath
def confirmInstallation() -> bool:
"""Ask a double-click user before changing files or the registry."""
if os.name != 'nt':
return False
message = (
'Install cewe2pdf for this Windows user?\n\n'
'This copies the executable to your local application folder and adds\n'
'“Create PDF with cewe2pdf” to the right-click menu for MCF and MCFX files.')
# MB_YESNO | MB_ICONQUESTION. IDYES is 6.
return ctypes.windll.user32.MessageBoxW(None, message, APPLICATION_NAME, 0x24) == 6
def showMessage(message: str, error: bool = False) -> None:
"""Show a short native Windows message without adding a GUI dependency."""
if os.name == 'nt':
icon = 0x10 if error else 0x40 # MB_ICONERROR / MB_ICONINFORMATION
ctypes.windll.user32.MessageBoxW(None, message, APPLICATION_NAME, icon)
else:
print(message)