upd vpn clip
This commit is contained in:
parent
d3bf08c523
commit
431147e017
111
README.md
111
README.md
|
|
@ -105,3 +105,114 @@ To check: `ip link show tun0` or `sudo cat /tmp/vpn-sophos.log`.
|
|||
### Account lockout
|
||||
Sophos locks the account after several failed AUTH attempts. Wait ~5 minutes
|
||||
before retrying after multiple failures.
|
||||
|
||||
---
|
||||
|
||||
## vpn-clip.py — GUI Clipboard Tool
|
||||
|
||||
PyQt6 tool to manage VPN profiles (password + TOTP) and copy credentials to the
|
||||
Wayland clipboard. Useful when `vpn-connect.sh` is not an option (e.g. manual
|
||||
OpenVPN clients, other VPN systems).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
# PyQt6 (usually already present on KDE Plasma)
|
||||
sudo pacman -S python-pyqt6
|
||||
|
||||
# oathtool and wl-clipboard (wl-copy) — already needed by vpn-connect.sh
|
||||
sudo pacman -S oath-toolkit wl-clipboard
|
||||
|
||||
# Optional but strongly recommended: encrypt stored profiles
|
||||
sudo pacman -S python-cryptography
|
||||
# or via pip inside a venv:
|
||||
pip install cryptography
|
||||
```
|
||||
|
||||
> **Without `cryptography`:** profiles are stored as plain base64-encoded JSON —
|
||||
> anyone with file access can read them. A warning banner is shown in the UI.
|
||||
> Install `python-cryptography` to get real Fernet/AES encryption with
|
||||
> PBKDF2-derived keys.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
python3 /path/to/fastvpn/vpn-clip.py
|
||||
```
|
||||
|
||||
- **First run:** no profiles file exists → prompted to set a master password,
|
||||
then immediately asked to create the first profile.
|
||||
- **Subsequent runs:** enter master password to decrypt and load profiles.
|
||||
- Select profile from dropdown.
|
||||
- Click **PW+OTP kopieren** to concatenate password + current TOTP and pipe it
|
||||
to `wl-copy`. If no OTP secret is set, only the password is copied.
|
||||
- The timer label shows seconds remaining in the current 30s TOTP window
|
||||
(turns red below 8s — consider waiting for the next window).
|
||||
- Click **Profil bearbeiten** to open the profile manager (add / edit / delete).
|
||||
|
||||
### Profile storage
|
||||
|
||||
Profiles are stored in `vpn-profiles.enc` next to the script.
|
||||
|
||||
With `cryptography` installed the file layout is:
|
||||
|
||||
```
|
||||
[2 bytes: salt length] [16 bytes: random salt] [Fernet token]
|
||||
```
|
||||
|
||||
Key derivation: PBKDF2-HMAC-SHA256, 480 000 iterations, 32-byte key.
|
||||
|
||||
### Installation as desktop app
|
||||
|
||||
To make `vpn-clip.py` appear in the application launcher and allow pinning to the taskbar:
|
||||
|
||||
```bash
|
||||
# 1. Make executable
|
||||
chmod +x /path/to/fastvpn/vpn-clip.py
|
||||
|
||||
# 2. Create .desktop entry (adjust Exec= path)
|
||||
cat > ~/.local/share/applications/vpn-clip.desktop << 'EOF'
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=VPN Clip
|
||||
Comment=VPN password+OTP clipboard tool
|
||||
Exec=/path/to/fastvpn/vpn-clip.py
|
||||
Icon=network-vpn
|
||||
Terminal=false
|
||||
Categories=Network;Security;Utility;
|
||||
Keywords=vpn;otp;password;clipboard;
|
||||
StartupNotify=true
|
||||
EOF
|
||||
|
||||
# 3. Refresh launcher database
|
||||
update-desktop-database ~/.local/share/applications/
|
||||
```
|
||||
|
||||
After step 3 the app appears under **Network** / **Utilities** in the start menu.
|
||||
Right-click the icon → **Pin to taskbar** (KDE: "Add to panel").
|
||||
|
||||
To use a custom icon instead of the system `network-vpn` icon, place a PNG at
|
||||
`~/.local/share/icons/vpn-clip.png` and set `Icon=vpn-clip` in the `.desktop` file.
|
||||
|
||||
### Pitfalls (vpn-clip)
|
||||
|
||||
**`cryptography` not on PATH / wrong Python**
|
||||
`pacman -S python-cryptography` installs for the system Python. If you run the
|
||||
script with a venv or a different Python binary the package may not be found and
|
||||
the fallback kicks in silently — check the warning banner in the UI.
|
||||
|
||||
**wl-copy requires a running Wayland session**
|
||||
Running the script over SSH without a forwarded Wayland socket will make
|
||||
`wl-copy` fail. The error is shown in a dialog box.
|
||||
|
||||
**OTP secret format**
|
||||
`oathtool --totp -b` expects a base32-encoded secret (the "seed" shown by most
|
||||
authenticator apps as a QR code alternative). Spaces in the secret are fine;
|
||||
`oathtool` ignores them.
|
||||
|
||||
**Password + OTP concatenation**
|
||||
The tool concatenates password and OTP with no separator (e.g. `hunter2123456`).
|
||||
Sophos and most other SSL VPN gateways expect exactly this format in the
|
||||
password field. If your gateway uses a different format, edit `_copy()` in the
|
||||
script.
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,466 @@
|
|||
#!/usr/bin/env python3
|
||||
"""VPN clipboard tool — copies password+OTP for VPN profiles."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Crypto layer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROFILES_PATH = Path(__file__).parent / "vpn-profiles.enc"
|
||||
|
||||
try:
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
import cryptography.hazmat.primitives.kdf.pbkdf2 as _pbkdf2
|
||||
from cryptography.hazmat.primitives import hashes as _hashes
|
||||
HAVE_CRYPTO = True
|
||||
except ImportError:
|
||||
HAVE_CRYPTO = False
|
||||
|
||||
|
||||
def _derive_key(password: str, salt: bytes) -> bytes:
|
||||
"""Derive a Fernet key from master password + salt."""
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=480_000,
|
||||
)
|
||||
return base64.urlsafe_b64encode(kdf.derive(password.encode()))
|
||||
|
||||
|
||||
def encrypt_profiles(profiles: list, master_password: str) -> bytes:
|
||||
"""Serialize and encrypt profile list."""
|
||||
raw = json.dumps(profiles).encode()
|
||||
if HAVE_CRYPTO:
|
||||
salt = os.urandom(16)
|
||||
key = _derive_key(master_password, salt)
|
||||
token = Fernet(key).encrypt(raw)
|
||||
# layout: 2-byte salt-len + salt + fernet token
|
||||
return len(salt).to_bytes(2, "big") + salt + token
|
||||
else:
|
||||
# fallback: base64-encoded JSON (no real encryption)
|
||||
return base64.b64encode(raw)
|
||||
|
||||
|
||||
def decrypt_profiles(data: bytes, master_password: str) -> list:
|
||||
"""Decrypt and deserialize profile list. Raises ValueError on wrong password."""
|
||||
if HAVE_CRYPTO:
|
||||
salt_len = int.from_bytes(data[:2], "big")
|
||||
salt = data[2 : 2 + salt_len]
|
||||
token = data[2 + salt_len :]
|
||||
key = _derive_key(master_password, salt)
|
||||
try:
|
||||
raw = Fernet(key).decrypt(token)
|
||||
except InvalidToken:
|
||||
raise ValueError("Falsches Master-Passwort")
|
||||
return json.loads(raw)
|
||||
else:
|
||||
try:
|
||||
return json.loads(base64.b64decode(data))
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Datei konnte nicht gelesen werden: {exc}") from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OTP helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_otp(secret: str) -> str | None:
|
||||
"""Return current TOTP value or None on failure."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["oathtool", "--totp", "-b", secret],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def otp_seconds_remaining() -> int:
|
||||
"""Seconds left in current 30s TOTP window."""
|
||||
return 30 - (int(time.time()) % 30)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Clipboard helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def copy_to_clipboard(value: str) -> None:
|
||||
subprocess.run(["wl-copy", value], check=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile edit dialog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ProfileDialog(QDialog):
|
||||
def __init__(self, parent=None, profile: dict | None = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Profil bearbeiten")
|
||||
self._deleted = False
|
||||
|
||||
layout = QFormLayout()
|
||||
|
||||
self.name_edit = QLineEdit(profile["name"] if profile else "")
|
||||
self.pw_edit = QLineEdit(profile.get("password", "") if profile else "")
|
||||
self.pw_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.otp_edit = QLineEdit(profile.get("otp_secret", "") if profile else "")
|
||||
|
||||
layout.addRow("Name:", self.name_edit)
|
||||
layout.addRow("Passwort:", self.pw_edit)
|
||||
layout.addRow("OTP-Schlüssel (optional):", self.otp_edit)
|
||||
|
||||
btn_box = QDialogButtonBox()
|
||||
save_btn = btn_box.addButton("Speichern", QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
cancel_btn = btn_box.addButton("Abbrechen", QDialogButtonBox.ButtonRole.RejectRole) # noqa: F841
|
||||
delete_btn = btn_box.addButton("Löschen", QDialogButtonBox.ButtonRole.DestructiveRole)
|
||||
|
||||
save_btn.clicked.connect(self._save)
|
||||
delete_btn.clicked.connect(self._delete)
|
||||
btn_box.rejected.connect(self.reject)
|
||||
|
||||
# hide delete on new profile
|
||||
delete_btn.setVisible(profile is not None)
|
||||
|
||||
outer = QVBoxLayout()
|
||||
outer.addLayout(layout)
|
||||
outer.addWidget(btn_box)
|
||||
self.setLayout(outer)
|
||||
|
||||
def _save(self):
|
||||
if not self.name_edit.text().strip():
|
||||
QMessageBox.warning(self, "Fehler", "Name darf nicht leer sein.")
|
||||
return
|
||||
if not self.pw_edit.text():
|
||||
QMessageBox.warning(self, "Fehler", "Passwort darf nicht leer sein.")
|
||||
return
|
||||
self.accept()
|
||||
|
||||
def _delete(self):
|
||||
if QMessageBox.question(
|
||||
self, "Löschen", "Profil wirklich löschen?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
) == QMessageBox.StandardButton.Yes:
|
||||
self._deleted = True
|
||||
self.accept()
|
||||
|
||||
def result_profile(self) -> dict:
|
||||
return {
|
||||
"name": self.name_edit.text().strip(),
|
||||
"password": self.pw_edit.text(),
|
||||
"otp_secret": self.otp_edit.text().strip(),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profiles manager dialog (list + add/edit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ProfileManagerDialog(QDialog):
|
||||
def __init__(self, parent, profiles: list, master_password: str):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Profile verwalten")
|
||||
self.profiles = list(profiles)
|
||||
self.master_password = master_password
|
||||
|
||||
self._list = QComboBox()
|
||||
self._refresh_list()
|
||||
|
||||
add_btn = QPushButton("Neu")
|
||||
edit_btn = QPushButton("Bearbeiten")
|
||||
close_btn = QPushButton("Schließen")
|
||||
|
||||
add_btn.clicked.connect(self._add)
|
||||
edit_btn.clicked.connect(self._edit)
|
||||
close_btn.clicked.connect(self.accept)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addWidget(add_btn)
|
||||
btn_row.addWidget(edit_btn)
|
||||
btn_row.addStretch()
|
||||
btn_row.addWidget(close_btn)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(QLabel("Profil:"))
|
||||
layout.addWidget(self._list)
|
||||
layout.addLayout(btn_row)
|
||||
self.setLayout(layout)
|
||||
|
||||
def _refresh_list(self):
|
||||
self._list.clear()
|
||||
for p in self.profiles:
|
||||
self._list.addItem(p["name"])
|
||||
|
||||
def _add(self):
|
||||
dlg = ProfileDialog(self)
|
||||
if dlg.exec() == QDialog.DialogCode.Accepted and not dlg._deleted:
|
||||
self.profiles.append(dlg.result_profile())
|
||||
self._save()
|
||||
self._refresh_list()
|
||||
self._list.setCurrentIndex(len(self.profiles) - 1)
|
||||
|
||||
def _edit(self):
|
||||
idx = self._list.currentIndex()
|
||||
if idx < 0:
|
||||
return
|
||||
dlg = ProfileDialog(self, self.profiles[idx])
|
||||
if dlg.exec() == QDialog.DialogCode.Accepted:
|
||||
if dlg._deleted:
|
||||
self.profiles.pop(idx)
|
||||
else:
|
||||
self.profiles[idx] = dlg.result_profile()
|
||||
self._save()
|
||||
self._refresh_list()
|
||||
|
||||
def _save(self):
|
||||
data = encrypt_profiles(self.profiles, self.master_password)
|
||||
PROFILES_PATH.write_bytes(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Master password dialog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MasterPasswordDialog(QDialog):
|
||||
def __init__(self, parent=None, confirm: bool = False):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Master-Passwort")
|
||||
|
||||
self._pw = QLineEdit()
|
||||
self._pw.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self._pw.setPlaceholderText("Master-Passwort eingeben …")
|
||||
|
||||
self._pw2: QLineEdit | None = None
|
||||
layout = QFormLayout()
|
||||
layout.addRow("Passwort:", self._pw)
|
||||
|
||||
if confirm:
|
||||
self._pw2 = QLineEdit()
|
||||
self._pw2.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self._pw2.setPlaceholderText("Wiederholen …")
|
||||
layout.addRow("Bestätigung:", self._pw2)
|
||||
|
||||
btn_box = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
btn_box.accepted.connect(self._check)
|
||||
btn_box.rejected.connect(self.reject)
|
||||
|
||||
outer = QVBoxLayout()
|
||||
outer.addLayout(layout)
|
||||
outer.addWidget(btn_box)
|
||||
self.setLayout(outer)
|
||||
self._pw.returnPressed.connect(btn_box.accepted.emit)
|
||||
|
||||
def _check(self):
|
||||
if not self._pw.text():
|
||||
QMessageBox.warning(self, "Fehler", "Passwort darf nicht leer sein.")
|
||||
return
|
||||
if self._pw2 is not None and self._pw.text() != self._pw2.text():
|
||||
QMessageBox.warning(self, "Fehler", "Passwörter stimmen nicht überein.")
|
||||
return
|
||||
self.accept()
|
||||
|
||||
def password(self) -> str:
|
||||
return self._pw.text()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main window
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MainWindow(QWidget):
|
||||
def __init__(self, profiles: list, master_password: str):
|
||||
super().__init__()
|
||||
self.profiles = profiles
|
||||
self.master_password = master_password
|
||||
|
||||
self.setWindowTitle("VPN Clip")
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.Window | Qt.WindowType.WindowStaysOnTopHint
|
||||
)
|
||||
|
||||
self._combo = QComboBox()
|
||||
self._timer_label = QLabel()
|
||||
self._copy_btn = QPushButton("PW+OTP kopieren")
|
||||
self._edit_btn = QPushButton("Profil bearbeiten")
|
||||
|
||||
self._copy_btn.clicked.connect(self._copy)
|
||||
self._edit_btn.clicked.connect(self._open_manager)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(QLabel("Profil:"))
|
||||
layout.addWidget(self._combo)
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self._copy_btn)
|
||||
row.addWidget(self._timer_label)
|
||||
layout.addLayout(row)
|
||||
layout.addWidget(self._edit_btn)
|
||||
|
||||
if not HAVE_CRYPTO:
|
||||
warn = QLabel(
|
||||
"⚠ cryptography nicht installiert — Daten nur base64-kodiert gespeichert."
|
||||
)
|
||||
warn.setStyleSheet("color: orange;")
|
||||
warn.setWordWrap(True)
|
||||
layout.addWidget(warn)
|
||||
|
||||
self.setLayout(layout)
|
||||
self._refresh_combo()
|
||||
|
||||
self._tick_timer = QTimer(self)
|
||||
self._tick_timer.timeout.connect(self._update_timer_label)
|
||||
self._tick_timer.start(500)
|
||||
self._update_timer_label()
|
||||
|
||||
def _refresh_combo(self, select_name: str | None = None):
|
||||
self._combo.clear()
|
||||
for p in self.profiles:
|
||||
self._combo.addItem(p["name"])
|
||||
if select_name:
|
||||
idx = self._combo.findText(select_name)
|
||||
if idx >= 0:
|
||||
self._combo.setCurrentIndex(idx)
|
||||
|
||||
def _current_profile(self) -> dict | None:
|
||||
idx = self._combo.currentIndex()
|
||||
if idx < 0 or idx >= len(self.profiles):
|
||||
return None
|
||||
return self.profiles[idx]
|
||||
|
||||
def _update_timer_label(self):
|
||||
profile = self._current_profile()
|
||||
if profile and profile.get("otp_secret"):
|
||||
secs = otp_seconds_remaining()
|
||||
self._timer_label.setText(f"OTP gültig: {secs}s")
|
||||
color = "green" if secs > 8 else "red"
|
||||
self._timer_label.setStyleSheet(f"color: {color};")
|
||||
else:
|
||||
self._timer_label.setText("")
|
||||
|
||||
def _copy(self):
|
||||
profile = self._current_profile()
|
||||
if not profile:
|
||||
QMessageBox.warning(self, "Fehler", "Kein Profil ausgewählt.")
|
||||
return
|
||||
|
||||
password = profile.get("password", "")
|
||||
secret = profile.get("otp_secret", "").strip()
|
||||
|
||||
if secret:
|
||||
otp = get_otp(secret)
|
||||
if otp is None:
|
||||
QMessageBox.critical(
|
||||
self, "Fehler",
|
||||
"OTP konnte nicht berechnet werden.\n"
|
||||
"Prüfe ob oathtool installiert und der OTP-Schlüssel korrekt ist.",
|
||||
)
|
||||
return
|
||||
value = password + otp
|
||||
else:
|
||||
value = password
|
||||
|
||||
try:
|
||||
copy_to_clipboard(value)
|
||||
except Exception as exc:
|
||||
QMessageBox.critical(self, "Fehler", f"wl-copy fehlgeschlagen:\n{exc}")
|
||||
return
|
||||
|
||||
label = "PW+OTP" if secret else "Passwort"
|
||||
self._copy_btn.setText(f"{label} kopiert ✓")
|
||||
QTimer.singleShot(2000, lambda: self._copy_btn.setText("PW+OTP kopieren"))
|
||||
|
||||
def _open_manager(self):
|
||||
dlg = ProfileManagerDialog(self, self.profiles, self.master_password)
|
||||
dlg.exec()
|
||||
# reload from file in case changes were saved
|
||||
try:
|
||||
data = PROFILES_PATH.read_bytes()
|
||||
self.profiles = decrypt_profiles(data, self.master_password)
|
||||
except Exception:
|
||||
pass
|
||||
selected = self._combo.currentText()
|
||||
self._refresh_combo(select_name=selected)
|
||||
self._update_timer_label()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("VPN Clip")
|
||||
|
||||
master_password = ""
|
||||
profiles: list = []
|
||||
|
||||
if PROFILES_PATH.exists():
|
||||
# Ask for master password and try to decrypt
|
||||
while True:
|
||||
dlg = MasterPasswordDialog()
|
||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||
sys.exit(0)
|
||||
master_password = dlg.password()
|
||||
try:
|
||||
profiles = decrypt_profiles(PROFILES_PATH.read_bytes(), master_password)
|
||||
break
|
||||
except ValueError as exc:
|
||||
QMessageBox.critical(None, "Fehler", str(exc))
|
||||
else:
|
||||
# First run: set master password, then create first profile
|
||||
QMessageBox.information(
|
||||
None,
|
||||
"Erster Start",
|
||||
"Keine Profile gefunden. Bitte lege ein Master-Passwort fest und erstelle ein Profil.",
|
||||
)
|
||||
dlg = MasterPasswordDialog(confirm=True)
|
||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||
sys.exit(0)
|
||||
master_password = dlg.password()
|
||||
|
||||
# Create first profile
|
||||
pdlg = ProfileDialog()
|
||||
if pdlg.exec() != QDialog.DialogCode.Accepted or pdlg._deleted:
|
||||
sys.exit(0)
|
||||
profiles = [pdlg.result_profile()]
|
||||
data = encrypt_profiles(profiles, master_password)
|
||||
PROFILES_PATH.write_bytes(data)
|
||||
|
||||
win = MainWindow(profiles, master_password)
|
||||
win.resize(320, 160)
|
||||
win.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Loading…
Reference in New Issue