Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba9bc8b17 | ||
|
|
e3442d7aa4 | ||
|
|
32095de02f | ||
|
|
cb306b7f2d | ||
|
|
ca902d39b3 | ||
|
|
b187f6c842 | ||
|
|
41ba74101a |
5
CONTRIBUTORS.txt
Normal file
5
CONTRIBUTORS.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
ali gholipour
|
||||
ars101
|
||||
hussein eslami
|
||||
meshya
|
||||
sohrab behdani
|
||||
23
README.md
23
README.md
@@ -6,26 +6,19 @@ A simple gui tool for set dns settings based on GTK4, related to [parchlinux pro
|
||||
|
||||

|
||||
|
||||
## run
|
||||
## Branch V1
|
||||
|
||||
It not released in a poppular repository yet, to use it you may use parchlinux repositories `pacman -S namban` or download package files from release page, or use a develpment environment.
|
||||
This branch is about changing app ui, refactor, add DoH and DoT support. contributors are welcome.
|
||||
|
||||
## develop environment
|
||||
|
||||
1. python > 3.10
|
||||
2. Gtk > 4.10
|
||||
3. PyGObject
|
||||
4. A glass of milk for mental peace
|
||||
3. Adw
|
||||
4. PyGObject
|
||||
5. A lot of milk for mental peace
|
||||
|
||||
## To Do
|
||||
- [x] Make a Icon! ( Done by HUSS )
|
||||
- [x] Fix text color in darkmode
|
||||
- [x] Send maintainer to therapy ( Done by sohrab and HUSS )
|
||||
- [x] publish in parch repos ( Done by sohrab )
|
||||
- [ ] publish in AUR
|
||||
- [X] Add build file for debian ( Done by ARS101 )
|
||||
- [X] Create a CI/CD 4 debian ( Done by ARS101 )
|
||||
- [X] Add build file for redhat ( Done by sohrab )
|
||||
- [ ] Error Message for wrong input [#5](https://github.com/parchlinuxB/namban/issues/5)
|
||||
## Far To Do
|
||||
- [ ] DoH & DoT support
|
||||
- [ ] DoH & DoT support.
|
||||
- [ ] Rewrite with new design in js.
|
||||
- [ ] Make sure it's compatible with previous version.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgname=namban
|
||||
pkgver=0.3
|
||||
pkgrel=5
|
||||
pkgrel=7
|
||||
pkgdesc="use custom dns has never been hard"
|
||||
arch=('any')
|
||||
url="https://github.com/parchlinuxb/namban"
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import gi
|
||||
gi.require_version("Gtk","4.0")
|
||||
gi.require_version("Gdk","4.0")
|
||||
from gi.repository import Gtk
|
||||
from view import window
|
||||
import settings
|
||||
import storage
|
||||
import sys
|
||||
import control
|
||||
import core
|
||||
import checks
|
||||
class application(Gtk.Application, control.control, storage.storage, core.core):
|
||||
def __init__(self):
|
||||
Gtk.Application.__init__(self,
|
||||
application_id=settings.PACKAGE_NAME
|
||||
)
|
||||
self.connect('activate', self.activate)
|
||||
self.connectedProfile = None
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self.read()
|
||||
|
||||
def activate(self,*args):
|
||||
win = window(app=self)
|
||||
win.present()
|
||||
|
||||
def run(self,*d,**dd):
|
||||
super().run(*d,**dd)
|
||||
self.disconnectProfiles()
|
||||
|
||||
|
||||
def main() :
|
||||
return application().run([])
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
class LowFields(Exception):
|
||||
def __init__(self, *fields):
|
||||
super().__init__(
|
||||
f"This fields is esential: {', '.join(fields)}"
|
||||
)
|
||||
self.fields = fields
|
||||
class Field:
|
||||
def __init__(self, optional=False):
|
||||
self.optional = optional
|
||||
self.name = None
|
||||
def set_name(self,_name):
|
||||
self.name = _name
|
||||
class Model:
|
||||
def __init_subclass__(cls) -> None:
|
||||
cls._fields:list[Field] = []
|
||||
for name, obj in cls.__dict__.items():
|
||||
if isinstance(obj, Field):
|
||||
obj.set_name(name)
|
||||
cls._fields.append(obj)
|
||||
def __init__(self,**wargs) -> None:
|
||||
errorFields = []
|
||||
for f in self._fields:
|
||||
if f.name not in wargs:
|
||||
if f.optional:
|
||||
wargs[f.name] = None
|
||||
else :
|
||||
errorFields.append(f)
|
||||
if errorFields :
|
||||
raise LowFields(*errorFields)
|
||||
for name, val in wargs.items():
|
||||
if name in list(map(lambda x:x.name, self._fields)):
|
||||
setattr(
|
||||
self, name, val
|
||||
)
|
||||
def __eq__(self,another):
|
||||
class oooooo1:...
|
||||
class oooooo2:...
|
||||
for f in self._fields:
|
||||
if getattr(self,f.name,oooooo1) != getattr(another,f.name,oooooo2):
|
||||
return False
|
||||
return True
|
||||
@@ -1,35 +0,0 @@
|
||||
|
||||
import os
|
||||
import settings
|
||||
import re
|
||||
import json
|
||||
from core.basePromise import base_promise
|
||||
|
||||
|
||||
def checkPromises():
|
||||
promiseFiles = os.listdir(settings.PROMISES_PATH)
|
||||
for pf in filter(lambda n: re.match(r".+\.promise",os.path.basename(n)) ,promiseFiles):
|
||||
try:
|
||||
file = open(pf,"r")
|
||||
row = file.read()
|
||||
file.close()
|
||||
js = json.loads(row)
|
||||
name = js['name']
|
||||
PromiseClass = base_promise.FindPromiseClass(name)
|
||||
promise = PromiseClass.loadfromfile(js['dist'])
|
||||
promise.handle()
|
||||
except:
|
||||
os.remove(pf)
|
||||
|
||||
def checkPaths():
|
||||
def makSure(f):
|
||||
if not os.path.exists(f):
|
||||
os.mkdir(f)
|
||||
for p in settings._imps:
|
||||
makSure(p)
|
||||
|
||||
|
||||
def checkStartupService():
|
||||
os.system(
|
||||
'systemctl enable namban-startup-check'
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
import domain as domain
|
||||
import storage
|
||||
|
||||
class control:
|
||||
def addProfile(self, p:domain.profile):
|
||||
data = self.read()
|
||||
data.profiles.append(p)
|
||||
self.write(data)
|
||||
self.window.update()
|
||||
def deleteProfile(self, p:domain.profile):
|
||||
data = self.read()
|
||||
data.profiles.remove(p)
|
||||
self.write(data)
|
||||
self.window.update()
|
||||
def editProfile(self, p:domain.profile, newp:domain.profile):
|
||||
def indexOfProf(all,p):
|
||||
i = 0
|
||||
for c in all:
|
||||
if c==p:
|
||||
return i
|
||||
i += 1
|
||||
data = self.read()
|
||||
data.profiles[indexOfProf(data.profiles,p)] = newp
|
||||
self.write(data)
|
||||
def connectProfile(self, profile:domain.profile):
|
||||
self.connectedProfile = profile
|
||||
self.systemDnsSet(profile)
|
||||
self.window.update()
|
||||
def disconnectProfiles(self):
|
||||
self.connectedProfile = None
|
||||
self.systemDnsSet(None)
|
||||
@@ -1,15 +0,0 @@
|
||||
import domain
|
||||
from .strategy import resolvd
|
||||
class core:
|
||||
def systemDnsSet(self,profile:domain.profile):
|
||||
if profile == None:
|
||||
self.turnOffDns()
|
||||
return
|
||||
self.currentStrategy = resolvd(profile)
|
||||
self.currentStrategy.connect()
|
||||
|
||||
|
||||
def turnOffDns(self):
|
||||
if self.currentStrategy:
|
||||
self.currentStrategy.disconnect()
|
||||
self.currentStrategy = None
|
||||
@@ -1,39 +0,0 @@
|
||||
import os
|
||||
import settings
|
||||
import json
|
||||
import hashlib
|
||||
class base_promise:
|
||||
def __init__(self, **d) -> None:
|
||||
for name, v in d.items():
|
||||
setattr(self,name,v)
|
||||
dist = self.dump2file()
|
||||
fp = settings.PROMISES_PATH/(hashlib.sha256(bytes(dist,"UTF-8")).hexdigest() + ".promise")
|
||||
self.filePath = fp
|
||||
f = open(fp, "w+")
|
||||
f.write(
|
||||
json.dumps( {"name":type(self).__name__ ,"dist":dist} )
|
||||
)
|
||||
f.close()
|
||||
os.chmod(fp, 0o400)
|
||||
subs = []
|
||||
fields = []
|
||||
def handle(self):
|
||||
os.remove(self.filePath)
|
||||
def dump2file(self):
|
||||
d = {}
|
||||
for f in self.fields:
|
||||
d[f] = getattr(self,f)
|
||||
return json.dumps(d)
|
||||
@classmethod
|
||||
def loadfromfile(cls,data):
|
||||
d = json.loads(data)
|
||||
obj = cls(**d)
|
||||
return obj
|
||||
def __init_subclass__(cls) -> None:
|
||||
base_promise.subs.append(cls)
|
||||
@classmethod
|
||||
def FindPromiseClass(cls, name):
|
||||
try:
|
||||
return list(filter(lambda c:c.__name__ == name,cls.subs))[0]
|
||||
except:
|
||||
return
|
||||
@@ -1,6 +0,0 @@
|
||||
class base_strategy:
|
||||
def __init__(self, profile) -> None:
|
||||
self.promises = []
|
||||
self.profile = profile
|
||||
def connect (self):...
|
||||
def disconnect(self):...
|
||||
@@ -1,16 +0,0 @@
|
||||
import os
|
||||
from .basePromise import base_promise
|
||||
class resolvd(base_promise):
|
||||
fields = ['path']
|
||||
def __init__(self, path="",**d):
|
||||
super().__init__(path=path,**d)
|
||||
def handle(self):
|
||||
os.system(f'mv {self.path} /etc/systemd/resolved.conf')
|
||||
super().handle()
|
||||
class resolv_conf(base_promise):
|
||||
fields = ['path']
|
||||
def __init__(self, path="",**d):
|
||||
super().__init__(path=path,**d)
|
||||
def handle(self):
|
||||
os.system(f'mv {self.path} /etc/resolv.conf')
|
||||
super().handle()
|
||||
@@ -1,63 +0,0 @@
|
||||
from .baseSterategy import base_strategy
|
||||
import domain
|
||||
import os
|
||||
import settings
|
||||
from .promises import resolvd as resolvdPromise, resolv_conf as resolvConfPromise
|
||||
|
||||
class resolvd(base_strategy):
|
||||
def check4resovConfFile(self):
|
||||
def isOk():
|
||||
d = open('/etc/resolv.conf','r').read()
|
||||
for line in d.splitlines():
|
||||
try:
|
||||
line = line.strip()
|
||||
if line[:10] == 'nameserver':
|
||||
return (line[10:]).strip() == '127.0.0.53'
|
||||
except:...
|
||||
return False
|
||||
if not isOk():
|
||||
resolvconffile = f'''
|
||||
#Generated By NAMBAN
|
||||
|
||||
#Connect to systemd-resolvd
|
||||
nameserver 127.0.0.53
|
||||
'''.strip()
|
||||
os.system(
|
||||
"cp /etc/resolv.conf " + str(settings.APP_FILES_PATH/'oldconffiles/resolv.conf') + " -r"
|
||||
)
|
||||
with open('/etc/resolv.conf', 'w+') as f:
|
||||
f.write(resolvconffile)
|
||||
f.close()
|
||||
self.promises.append(
|
||||
resolvConfPromise((settings.APP_FILES_PATH/'oldconffiles/resolv.conf').__str__())
|
||||
)
|
||||
def generateResolvdConf(self):
|
||||
profile = self.profile
|
||||
c = f'''
|
||||
# Generated By NAMBAN
|
||||
[Resolve]
|
||||
DNS={profile.server1.url}
|
||||
'''.strip()
|
||||
if profile.server2:
|
||||
c += f'\nFallbackDNS={profile.server2.url}'
|
||||
return c
|
||||
def connect(self):
|
||||
self.check4resovConfFile()
|
||||
os.system(f"cp /etc/systemd/resolved.conf "+str(settings.APP_FILES_PATH/'oldconffiles/resolved.conf') + " -r")
|
||||
with open("/etc/systemd/resolved.conf",'w+') as f:
|
||||
f.write(
|
||||
self.generateResolvdConf()
|
||||
)
|
||||
f.close()
|
||||
os.system('systemctl daemon-reload')
|
||||
os.system('systemctl restart systemd-resolved')
|
||||
self.promises.append(
|
||||
resolvdPromise((settings.APP_FILES_PATH/'oldconffiles/resolved.conf').__str__())
|
||||
)
|
||||
def disconnect(self):
|
||||
while len(self.promises):
|
||||
p = self.promises.pop()
|
||||
p.handle()
|
||||
os.system('systemctl daemon-reload')
|
||||
os.system('systemctl restart systemd-resolved')
|
||||
|
||||
82
src/dns_manager.py
Normal file
82
src/dns_manager.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from models import DNSProfile, DNSType
|
||||
|
||||
class DNSManager:
|
||||
def __init__(self):
|
||||
self.config_path = Path("/etc/systemd/resolved.conf")
|
||||
self.backup_path = Path(f"/tmp/namban_resolved_backup_{os.getuid()}.conf")
|
||||
self.current_profile: Optional[DNSProfile] = None
|
||||
|
||||
def backup_current_config(self) -> bool:
|
||||
try:
|
||||
if self.config_path.exists():
|
||||
subprocess.run(['pkexec', 'cp', str(self.config_path), str(self.backup_path)], check=True, capture_output=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
return False
|
||||
|
||||
def restore_config(self) -> bool:
|
||||
try:
|
||||
if self.backup_path.exists():
|
||||
subprocess.run(['pkexec', 'mv', str(self.backup_path), str(self.config_path)], check=True, capture_output=True)
|
||||
self.reload_systemd_resolved()
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
return False
|
||||
|
||||
def apply_profile(self, profile: DNSProfile) -> bool:
|
||||
try:
|
||||
if not self.backup_current_config():
|
||||
pass
|
||||
config_content = self._generate_resolved_conf(profile)
|
||||
with subprocess.Popen(['pkexec', 'tee', str(self.config_path)], stdin=subprocess.PIPE, text=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) as process:
|
||||
_, stderr = process.communicate(input=config_content)
|
||||
if process.returncode != 0:
|
||||
raise subprocess.CalledProcessError(process.returncode, 'tee', stderr=stderr)
|
||||
|
||||
self.reload_systemd_resolved()
|
||||
self.current_profile = profile
|
||||
return True
|
||||
except Exception:
|
||||
self.restore_config()
|
||||
return False
|
||||
|
||||
def _generate_resolved_conf(self, profile: DNSProfile) -> str:
|
||||
lines = ["# Generated by Namban DNS Manager", "[Resolve]"]
|
||||
dns_servers = []
|
||||
is_doh = any(s.dns_type == DNSType.DOH for s in profile.servers)
|
||||
is_dot = any(s.dns_type == DNSType.DOT for s in profile.servers)
|
||||
|
||||
for server in profile.servers:
|
||||
dns_servers.append(server.primary)
|
||||
if server.secondary:
|
||||
dns_servers.append(server.secondary)
|
||||
|
||||
if dns_servers:
|
||||
lines.append(f"DNS={' '.join(dns_servers)}")
|
||||
if is_doh:
|
||||
lines.append("DNSOverHTTPS=yes")
|
||||
elif is_dot:
|
||||
lines.append("DNSOverTLS=opportunistic")
|
||||
|
||||
lines.extend(["DNSSEC=allow-downgrade", "DNSStubListener=yes", "Cache=yes", ""])
|
||||
return '\n'.join(lines)
|
||||
|
||||
def reload_systemd_resolved(self):
|
||||
subprocess.run(['pkexec', 'systemctl', 'restart', 'systemd-resolved'], check=True, capture_output=True)
|
||||
|
||||
def get_current_dns(self) -> List[str]:
|
||||
try:
|
||||
result = subprocess.run(['resolvectl', 'status'], capture_output=True, text=True, check=True)
|
||||
dns_servers = [line.split(':')[1].strip() for line in result.stdout.split('\n') if 'Current DNS Server:' in line]
|
||||
if not dns_servers:
|
||||
dns_servers = [s for line in result.stdout.split('\n') if 'DNS Servers:' in line for s in line.split(':')[1].strip().split()]
|
||||
return dns_servers
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return []
|
||||
@@ -1,23 +0,0 @@
|
||||
from base.domain import Model,Field
|
||||
|
||||
class server(Model):
|
||||
type = Field()
|
||||
url = Field()
|
||||
def __str__(self):
|
||||
return self.url
|
||||
|
||||
class profile(Model):
|
||||
server1 = Field()
|
||||
server2 = Field(optional=True)
|
||||
name = Field(optional=True)
|
||||
def __str__(self):
|
||||
return self.name | self.server1.__str__()
|
||||
|
||||
|
||||
class appData:
|
||||
def __init__(self, profiles:list[server]) -> None:
|
||||
self.profiles = profiles
|
||||
class app:
|
||||
def __init__(self, data:appData):
|
||||
self.data = data
|
||||
self.connectedProfile = None
|
||||
61
src/main.py
Normal file
61
src/main.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import gi
|
||||
gi.require_version('Gtk', '4.0')
|
||||
gi.require_version('Adw', '1')
|
||||
gi.require_version('Gio', '2.0')
|
||||
|
||||
import sys
|
||||
import logging
|
||||
from gi.repository import Gtk, Adw, Gio
|
||||
from main_window import MainWindow
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s:%(name)s:%(message)s')
|
||||
|
||||
class NambanApplication(Adw.Application):
|
||||
def __init__(self):
|
||||
super().__init__(application_id="com.parchlinux.namban", flags=Gio.ApplicationFlags.FLAGS_NONE)
|
||||
|
||||
def do_activate(self):
|
||||
win = self.get_active_window() or MainWindow(application=self)
|
||||
win.present()
|
||||
|
||||
def do_startup(self):
|
||||
Adw.Application.do_startup(self)
|
||||
Adw.StyleManager.get_default().set_color_scheme(Adw.ColorScheme.DEFAULT)
|
||||
|
||||
settings_action = Gio.SimpleAction.new("settings", None)
|
||||
settings_action.connect("activate", self._on_settings)
|
||||
self.add_action(settings_action)
|
||||
|
||||
about_action = Gio.SimpleAction.new("about", None)
|
||||
about_action.connect("activate", self._on_about)
|
||||
self.add_action(about_action)
|
||||
|
||||
quit_action = Gio.SimpleAction.new("quit", None)
|
||||
quit_action.connect("activate", lambda *_: self.quit())
|
||||
self.add_action(quit_action)
|
||||
self.set_accels_for_action("app.quit", ["<Ctrl>q"])
|
||||
|
||||
def _on_settings(self, _, __):
|
||||
win = self.get_active_window()
|
||||
if win:
|
||||
win._show_settings_sheet()
|
||||
|
||||
def _on_about(self, _, __):
|
||||
Adw.AboutWindow(
|
||||
transient_for=self.get_active_window(), application_name="Namban DNS Manager",
|
||||
application_icon="com.parchlinux.namban", developer_name="Parch Linux Team & Contributors",
|
||||
version="2.0.0", developers=["Meshya", "Sohrab Behdani"],
|
||||
copyright="© 2024 Parch Linux Team", license_type=Gtk.License.GPL_3_0_ONLY,
|
||||
website="https://github.com/parchlinuxb/namban",
|
||||
issue_url="https://github.com/parchlinuxb/namban/issues"
|
||||
).present()
|
||||
|
||||
def main():
|
||||
try:
|
||||
return NambanApplication().run(sys.argv)
|
||||
except Exception as e:
|
||||
logging.critical(f"Application failed to start: {e}", exc_info=True)
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
167
src/main_window.py
Normal file
167
src/main_window.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import gi
|
||||
from typing import List, Optional
|
||||
from gi.repository import Gtk, Adw, GLib, Gio
|
||||
from models import DNSProfile
|
||||
from dns_manager import DNSManager
|
||||
from profile_manager import ProfileManager
|
||||
from ui_components import ProfileRow, ProfileEditSheet, SettingsPage
|
||||
|
||||
class MainWindow(Adw.ApplicationWindow):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.dns_manager = DNSManager()
|
||||
self.profile_manager = ProfileManager()
|
||||
self.profile_rows: List[ProfileRow] = []
|
||||
self.active_profile: Optional[DNSProfile] = None
|
||||
|
||||
self.set_title("Namban DNS Manager")
|
||||
self.set_default_size(450, 600)
|
||||
self._build_ui()
|
||||
self._load_profiles()
|
||||
self._update_status()
|
||||
GLib.timeout_add_seconds(10, self._update_status)
|
||||
|
||||
def _build_ui(self):
|
||||
self.bottom_sheet = Adw.BottomSheet()
|
||||
self.toast_overlay = Adw.ToastOverlay(child=self.bottom_sheet)
|
||||
self.set_content(self.toast_overlay)
|
||||
self.bottom_sheet.set_content(self._build_main_content())
|
||||
|
||||
def _build_main_content(self):
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
header = Adw.HeaderBar(title_widget=Adw.WindowTitle(title="Namban DNS Manager"))
|
||||
add_button = Gtk.Button(icon_name="list-add-symbolic", tooltip_text="Add New Profile")
|
||||
add_button.connect('clicked', self._on_add_profile)
|
||||
header.pack_end(add_button)
|
||||
menu_button = Gtk.MenuButton(icon_name="open-menu-symbolic", menu_model=Gio.Menu.new())
|
||||
menu_button.get_menu_model().append("Settings", "app.settings")
|
||||
menu_button.get_menu_model().append("About", "app.about")
|
||||
menu_button.get_menu_model().append("Quit", "app.quit")
|
||||
header.pack_end(menu_button)
|
||||
toolbar_view.add_top_bar(header)
|
||||
|
||||
status_group = Adw.PreferencesGroup(title="Current Status")
|
||||
self.status_row = Adw.ActionRow(title="DNS Status", subtitle="Loading...")
|
||||
status_group.add(self.status_row)
|
||||
|
||||
self.profiles_group = Adw.PreferencesGroup(title="DNS Profiles", description="Select a profile to apply")
|
||||
page = Adw.PreferencesPage()
|
||||
page.add(status_group)
|
||||
page.add(self.profiles_group)
|
||||
toolbar_view.set_content(Gtk.ScrolledWindow(child=page, hscrollbar_policy='never'))
|
||||
return toolbar_view
|
||||
|
||||
def _load_profiles(self):
|
||||
for row in self.profile_rows:
|
||||
self.profiles_group.remove(row)
|
||||
self.profile_rows.clear()
|
||||
for profile in self.profile_manager.profiles:
|
||||
row = ProfileRow(profile,
|
||||
on_activate=self._on_profile_activate,
|
||||
on_edit=self._on_profile_edit,
|
||||
on_delete=self._on_profile_delete)
|
||||
self.profiles_group.add(row)
|
||||
self.profile_rows.append(row)
|
||||
|
||||
def _update_status(self):
|
||||
current_dns = self.dns_manager.get_current_dns()
|
||||
self.status_row.set_subtitle(", ".join(current_dns) or "System default")
|
||||
return True
|
||||
|
||||
def _on_profile_activate(self, profile: DNSProfile, state: bool):
|
||||
if state:
|
||||
if self.dns_manager.apply_profile(profile):
|
||||
self.toast_overlay.add_toast(Adw.Toast.new(f"Applied: {profile.name}"))
|
||||
self.active_profile = profile
|
||||
for row in self.profile_rows:
|
||||
if row.profile != profile:
|
||||
row.set_active(False)
|
||||
else:
|
||||
self.toast_overlay.add_toast(Adw.Toast.new("Failed to apply profile"))
|
||||
for row in self.profile_rows:
|
||||
if row.profile == profile:
|
||||
row.set_active(False)
|
||||
break
|
||||
elif self.active_profile == profile:
|
||||
if self.dns_manager.restore_config():
|
||||
self.toast_overlay.add_toast(Adw.Toast.new("Restored default DNS"))
|
||||
self.active_profile = None
|
||||
else:
|
||||
self.toast_overlay.add_toast(Adw.Toast.new("Failed to restore settings"))
|
||||
for row in self.profile_rows:
|
||||
if row.profile == profile:
|
||||
row.set_active(True)
|
||||
break
|
||||
self._update_status()
|
||||
|
||||
def _show_profile_sheet(self, old_profile: Optional[DNSProfile] = None):
|
||||
sheet_content = ProfileEditSheet(profile=old_profile)
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
title = "Edit Profile" if old_profile else "Add New Profile"
|
||||
header = Adw.HeaderBar(title_widget=Adw.WindowTitle(title=title))
|
||||
toolbar_view.add_top_bar(header)
|
||||
toolbar_view.set_content(sheet_content)
|
||||
|
||||
cancel_button = Gtk.Button(label="Cancel")
|
||||
cancel_button.connect('clicked', lambda _: self.bottom_sheet.set_open(False))
|
||||
header.pack_start(cancel_button)
|
||||
|
||||
save_button = Gtk.Button(label="Save", css_classes=["suggested-action"])
|
||||
header.pack_end(save_button)
|
||||
|
||||
def on_save(_):
|
||||
new_profile = sheet_content.get_profile()
|
||||
if new_profile:
|
||||
if old_profile:
|
||||
self._on_profile_updated(old_profile, new_profile)
|
||||
else:
|
||||
self._on_profile_added(new_profile)
|
||||
self.bottom_sheet.set_open(False)
|
||||
else:
|
||||
self.toast_overlay.add_toast(Adw.Toast.new("Profile Name and Primary DNS are required."))
|
||||
|
||||
save_button.connect('clicked', on_save)
|
||||
self.bottom_sheet.set_sheet(toolbar_view)
|
||||
self.bottom_sheet.set_open(True)
|
||||
|
||||
def _show_settings_sheet(self):
|
||||
settings_content = SettingsPage()
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
header = Adw.HeaderBar(title_widget=Adw.WindowTitle(title="Settings"))
|
||||
toolbar_view.add_top_bar(header)
|
||||
|
||||
clamp = Adw.Clamp(maximum_size=400, tightening_threshold=300)
|
||||
clamp.set_child(settings_content)
|
||||
toolbar_view.set_content(clamp)
|
||||
|
||||
close_button = Gtk.Button(label="Done")
|
||||
close_button.connect('clicked', lambda _: self.bottom_sheet.set_open(False))
|
||||
header.pack_end(close_button)
|
||||
|
||||
self.bottom_sheet.set_sheet(toolbar_view)
|
||||
self.bottom_sheet.set_open(True)
|
||||
|
||||
def _on_add_profile(self, _):
|
||||
self._show_profile_sheet()
|
||||
|
||||
def _on_profile_edit(self, profile: DNSProfile):
|
||||
self._show_profile_sheet(old_profile=profile)
|
||||
|
||||
def _on_profile_delete(self, profile: DNSProfile):
|
||||
self.profile_manager.remove_profile(profile)
|
||||
self._load_profiles()
|
||||
self.toast_overlay.add_toast(Adw.Toast.new(f"Profile '{profile.name}' removed"))
|
||||
if self.active_profile == profile:
|
||||
self.dns_manager.restore_config()
|
||||
self.active_profile = None
|
||||
self._update_status()
|
||||
|
||||
def _on_profile_added(self, profile: DNSProfile):
|
||||
self.profile_manager.add_profile(profile)
|
||||
self._load_profiles()
|
||||
self.toast_overlay.add_toast(Adw.Toast.new(f"Profile '{profile.name}' created"))
|
||||
|
||||
def _on_profile_updated(self, old_profile: DNSProfile, new_profile: DNSProfile):
|
||||
self.profile_manager.update_profile(old_profile, new_profile)
|
||||
self._load_profiles()
|
||||
self.toast_overlay.add_toast(Adw.Toast.new(f"Profile '{new_profile.name}' updated"))
|
||||
22
src/models.py
Normal file
22
src/models.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional, List
|
||||
|
||||
class DNSType(Enum):
|
||||
STANDARD = "standard"
|
||||
DOH = "doh"
|
||||
DOT = "dot"
|
||||
|
||||
@dataclass
|
||||
class DNSServer:
|
||||
name: str
|
||||
primary: str
|
||||
secondary: Optional[str] = None
|
||||
dns_type: DNSType = DNSType.STANDARD
|
||||
doh_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
class DNSProfile:
|
||||
def __init__(self, name: str, servers: List[DNSServer]):
|
||||
self.name = name
|
||||
self.servers = servers
|
||||
@@ -1,13 +0,0 @@
|
||||
import application
|
||||
import os
|
||||
import sys
|
||||
import checks
|
||||
if __name__ == "__main__":
|
||||
if os.getgid() != 0:
|
||||
print('Run it as root!')
|
||||
sys.exit(1)
|
||||
checks.checkStartupService()
|
||||
checks.checkPaths()
|
||||
sys.exit(
|
||||
application.main()
|
||||
)
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"--comment": [
|
||||
"This file is only for debug, effective one stored in:",
|
||||
"package/files/rootfs/etc/namban/config.json",
|
||||
"See also src/settings.py Line 13 dear aligholipour :)"
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"server1": {
|
||||
"type": "dns",
|
||||
"url": "1.1.1.1"
|
||||
},
|
||||
"name": "debug mode",
|
||||
"server2": {
|
||||
"type": "dns",
|
||||
"url": "1.0.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"server1": {
|
||||
"type": "dns",
|
||||
"url": "8.8.8.8"
|
||||
},
|
||||
"name": "google",
|
||||
"server2": {
|
||||
"type": "dns",
|
||||
"url": "8.8.4.4"
|
||||
}
|
||||
},{
|
||||
"server1": {
|
||||
"type": "dns",
|
||||
"url": "10.202.10.202"
|
||||
},
|
||||
"name": "403 online",
|
||||
"server2": {
|
||||
"type": "dns",
|
||||
"url": "10.202.10.102"
|
||||
}
|
||||
},{
|
||||
"server1": {
|
||||
"type": "dns",
|
||||
"url": "178.22.122.100"
|
||||
},
|
||||
"name": "shecan",
|
||||
"server2": {
|
||||
"type": "dns",
|
||||
"url": "185.51.200.2"
|
||||
}
|
||||
},{
|
||||
"server1": {
|
||||
"type": "dns",
|
||||
"url": "78.157.42.101"
|
||||
},
|
||||
"name": "electro",
|
||||
"server2": {
|
||||
"type": "dns",
|
||||
"url": "78.157.42.100"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
63
src/profile_manager.py
Normal file
63
src/profile_manager.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from models import DNSProfile, DNSServer, DNSType
|
||||
|
||||
class ProfileManager:
|
||||
def __init__(self):
|
||||
self.config_dir = Path.home() / '.config' / 'namban'
|
||||
self.config_file = self.config_dir / 'profiles.json'
|
||||
self.ensure_config_dir()
|
||||
self.profiles = self.load_profiles()
|
||||
|
||||
def ensure_config_dir(self):
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def load_profiles(self) -> List[DNSProfile]:
|
||||
default_profiles = [
|
||||
DNSProfile("Cloudflare", [DNSServer("Cloudflare", "1.1.1.1", "1.0.0.1")]),
|
||||
DNSProfile("Cloudflare DoH", [DNSServer("Cloudflare DoH", "1.1.1.1", "1.0.0.1", DNSType.DOH, "https://cloudflare-dns.com/dns-query")]),
|
||||
DNSProfile("Google", [DNSServer("Google", "8.8.8.8", "8.8.4.4")]),
|
||||
DNSProfile("Quad9", [DNSServer("Quad9", "9.9.9.9", "149.112.112.112")]),
|
||||
DNSProfile("OpenDNS", [DNSServer("OpenDNS", "208.67.222.222", "208.67.220.220")])
|
||||
]
|
||||
if not self.config_file.exists():
|
||||
self.save_profiles(default_profiles)
|
||||
return default_profiles
|
||||
try:
|
||||
with self.config_file.open('r') as f:
|
||||
return self._deserialize_profiles(json.load(f))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return default_profiles
|
||||
|
||||
def save_profiles(self, profiles: List[DNSProfile]):
|
||||
try:
|
||||
with self.config_file.open('w') as f:
|
||||
json.dump(self._serialize_profiles(profiles), f, indent=4)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _serialize_profiles(self, profiles: List[DNSProfile]) -> List[dict]:
|
||||
return [{'name': p.name, 'servers': [s.__dict__ | {'dns_type': s.dns_type.value} for s in p.servers]} for p in profiles]
|
||||
|
||||
def _deserialize_profiles(self, data: List[dict]) -> List[DNSProfile]:
|
||||
return [DNSProfile(item['name'], [DNSServer(**(s | {'dns_type': DNSType(s.get('dns_type', 'standard'))})) for s in item.get('servers', [])]) for item in data]
|
||||
|
||||
def add_profile(self, profile: DNSProfile):
|
||||
self.profiles.append(profile)
|
||||
self.save_profiles(self.profiles)
|
||||
|
||||
def remove_profile(self, profile: DNSProfile):
|
||||
try:
|
||||
self.profiles.remove(profile)
|
||||
self.save_profiles(self.profiles)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def update_profile(self, old_profile: DNSProfile, new_profile: DNSProfile):
|
||||
try:
|
||||
self.profiles[self.profiles.index(old_profile)] = new_profile
|
||||
self.save_profiles(self.profiles)
|
||||
except ValueError:
|
||||
pass
|
||||
27
src/proxy_manager.py
Normal file
27
src/proxy_manager.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Dict
|
||||
from gi.repository import Gio
|
||||
|
||||
class ProxyManager:
|
||||
def __init__(self):
|
||||
self.desktop_env = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
||||
|
||||
def get_proxy_settings(self) -> Dict:
|
||||
settings = {'enabled': False, 'host': '', 'port': 0}
|
||||
try:
|
||||
if 'gnome' in self.desktop_env:
|
||||
gsettings = Gio.Settings.new("org.gnome.system.proxy")
|
||||
if gsettings.get_string('mode') == 'manual':
|
||||
settings = {'enabled': True, 'host': gsettings.get_string('http-host'), 'port': gsettings.get_int('http-port')}
|
||||
elif 'kde' in self.desktop_env:
|
||||
proxy_type = subprocess.run(['kreadconfig5', '--group', 'Proxy Settings', '--key', 'ProxyType'], capture_output=True, text=True, check=False).stdout.strip()
|
||||
if proxy_type == '1':
|
||||
proxy_str = subprocess.run(['kreadconfig5', '--group', 'Proxy Settings', '--key', 'httpProxy'], capture_output=True, text=True, check=False).stdout.strip()
|
||||
if '://' in proxy_str:
|
||||
proxy_str = proxy_str.split('://')[1]
|
||||
host, port = proxy_str.split(':')
|
||||
settings = {'enabled': True, 'host': host, 'port': int(port)}
|
||||
except (Exception, FileNotFoundError):
|
||||
pass
|
||||
return settings
|
||||
178
src/pyproject.toml
Normal file
178
src/pyproject.toml
Normal file
@@ -0,0 +1,178 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "namban-dns"
|
||||
version = "2.0.0"
|
||||
description = "A modern GTK4/Adwaita DNS management application with DoH support"
|
||||
authors = [
|
||||
{name = "Ali Gholipour"},
|
||||
{name = "Hussein Eslami"},
|
||||
{name = "Sohrab Behdani"},
|
||||
{name = "Meshya"},
|
||||
{name = "ARS101"},
|
||||
]
|
||||
license = {text = "GPL-3.0"}
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: X11 Applications :: GTK",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: System :: Networking",
|
||||
"Topic :: System :: Systems Administration",
|
||||
]
|
||||
keywords = ["dns", "networking", "gtk4", "adwaita", "linux", "systemd"]
|
||||
|
||||
dependencies = [
|
||||
# Core GTK/GI bindings
|
||||
"PyGObject>=3.46.0",
|
||||
|
||||
# System integration
|
||||
"psutil>=5.9.0",
|
||||
"systemd-python>=235",
|
||||
|
||||
# Configuration and data handling
|
||||
"pydantic>=2.0.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
|
||||
# Network utilities
|
||||
"dnspython>=2.4.0",
|
||||
"requests>=2.31.0",
|
||||
"validators>=0.22.0",
|
||||
|
||||
# Logging and monitoring
|
||||
"structlog>=23.1.0",
|
||||
"rich>=13.0.0",
|
||||
|
||||
# Desktop integration (KDE/GNOME)
|
||||
"dbus-python>=1.3.2",
|
||||
"jeepney>=0.8.0", # Alternative D-Bus library
|
||||
|
||||
# Configuration file handling
|
||||
"tomli>=2.0.0; python_version < '3.11'",
|
||||
"tomli-w>=1.0.0",
|
||||
|
||||
# Additional utilities
|
||||
"click>=8.0.0", # For potential CLI interface
|
||||
"platformdirs>=3.0.0", # Cross-platform config directories
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
# Development tools
|
||||
"black>=23.0.0",
|
||||
"isort>=5.12.0",
|
||||
"flake8>=6.0.0",
|
||||
"mypy>=1.5.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-mock>=3.11.0",
|
||||
|
||||
# Documentation
|
||||
"sphinx>=7.0.0",
|
||||
"sphinx-rtd-theme>=1.3.0",
|
||||
|
||||
# Type stubs
|
||||
"types-requests>=2.31.0",
|
||||
"types-psutil>=5.9.0",
|
||||
]
|
||||
|
||||
testing = [
|
||||
"pytest>=7.4.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-mock>=3.11.0",
|
||||
]
|
||||
|
||||
packaging = [
|
||||
"build>=0.10.0",
|
||||
"wheel>=0.41.0",
|
||||
"twine>=4.0.0",
|
||||
]
|
||||
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/parchlinuxB/namban"
|
||||
Documentation = "https://github.com/parchlinuxB/namban#readme"
|
||||
Repository = "https://github.com/parchlinuxB/namban.git"
|
||||
"Bug Tracker" = "https://github.com/parchlinuxB/namban/issues"
|
||||
|
||||
[project.scripts]
|
||||
namban = "namban.main:main"
|
||||
namban-cli = "namban.cli:main"
|
||||
|
||||
[project.gui-scripts]
|
||||
namban-gui = "namban.main:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/namban"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = [
|
||||
"/src",
|
||||
"/package",
|
||||
"/README.md",
|
||||
"/LICENSE",
|
||||
"/namban.svg",
|
||||
]
|
||||
|
||||
# Black configuration
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py310']
|
||||
include = '\.pyi?$'
|
||||
extend-exclude = '''
|
||||
/(
|
||||
\.git
|
||||
| \.venv
|
||||
| build
|
||||
| dist
|
||||
)/
|
||||
'''
|
||||
|
||||
# isort configuration
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
multi_line_output = 3
|
||||
line_length = 88
|
||||
|
||||
# MyPy configuration
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
warn_no_return = true
|
||||
warn_unreachable = true
|
||||
strict_equality = true
|
||||
|
||||
# Coverage configuration
|
||||
[tool.coverage.run]
|
||||
source = ["src/namban"]
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"*/test_*",
|
||||
"*/__pycache__/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if __name__ == .__main__.:",
|
||||
"if TYPE_CHECKING:",
|
||||
]
|
||||
@@ -1,7 +0,0 @@
|
||||
class DataFile:
|
||||
def InvalidFile(*a):
|
||||
...
|
||||
def InvalidServer(*a):
|
||||
...
|
||||
def InvalidProfile(*a):
|
||||
...
|
||||
@@ -1,18 +0,0 @@
|
||||
import sys
|
||||
DEBUG = '--release' not in sys.argv
|
||||
|
||||
_imps = []
|
||||
def makeSureExists(p):
|
||||
_imps.append(p)
|
||||
return p
|
||||
|
||||
from pathlib import Path
|
||||
base = Path(__file__)
|
||||
PACKAGE_NAME = 'com.parchlinux.namban'
|
||||
WINDOW_TITLE = 'namban'
|
||||
APP_DATA_FILE = "./nambanAppdata.json" if DEBUG else "/etc/namban/config.json"
|
||||
SOCKET_PATH = Path("/tmp/namban.sock")
|
||||
APP_LOCK_PATH = Path("/tmp/namban.lck")
|
||||
APP_FILES_PATH = makeSureExists(Path("/usr/share/namban"))
|
||||
OLD_CONFS_PATH = makeSureExists(APP_FILES_PATH/'oldconffiles')
|
||||
PROMISES_PATH = makeSureExists(APP_FILES_PATH/'promises')
|
||||
@@ -1,8 +0,0 @@
|
||||
import checks
|
||||
|
||||
def check():
|
||||
checks.checkPaths()
|
||||
checks.checkPromises()
|
||||
|
||||
if __name__ == "__main__":
|
||||
check()
|
||||
@@ -1,74 +0,0 @@
|
||||
import domain
|
||||
import settings
|
||||
import report
|
||||
import os
|
||||
import json
|
||||
class storage:
|
||||
|
||||
def loadProf(self, rawProf):
|
||||
try:
|
||||
profWargs = {
|
||||
"server1" : self.loadServ(rawProf['server1']),
|
||||
"name": rawProf['name']
|
||||
}
|
||||
if "server2" in rawProf:
|
||||
profWargs['server2'] = self.loadServ(rawProf['server2'])
|
||||
return(
|
||||
domain.profile(**profWargs)
|
||||
)
|
||||
except:
|
||||
report.DataFile.InvalidProfile()
|
||||
def loadServ(self,rawServ)->domain.server:
|
||||
return domain.server(
|
||||
**rawServ
|
||||
)
|
||||
|
||||
def read(self):
|
||||
emptyConf = domain.appData([])
|
||||
if not os.path.exists(settings.APP_DATA_FILE):
|
||||
report.DataFile.InvalidFile()
|
||||
return emptyConf
|
||||
with open(settings.APP_DATA_FILE,"r") as f:
|
||||
try:
|
||||
rawData = json.loads(
|
||||
f.read()
|
||||
)
|
||||
except:
|
||||
report.DataFile.InvalidFile()
|
||||
return emptyConf
|
||||
profiles = []
|
||||
for rawProf in rawData.get('profiles',[]):
|
||||
r = self.loadProf(rawProf)
|
||||
if r:
|
||||
profiles.append(r)
|
||||
return domain.appData(
|
||||
profiles
|
||||
)
|
||||
|
||||
def write(self,appData:domain.appData):
|
||||
rawData = {
|
||||
"profiles":[
|
||||
self.dumpProf(p) for p in appData.profiles
|
||||
]
|
||||
}
|
||||
try:
|
||||
with open(settings.APP_DATA_FILE,"w+") as f:
|
||||
f.write(
|
||||
json.dumps(rawData, indent=4)
|
||||
)
|
||||
except:
|
||||
report.DataFile.InvalidFile()
|
||||
|
||||
def dumpServ(self, serv:domain.server) -> dict:
|
||||
return {
|
||||
'type' : serv.type,
|
||||
'url' : serv.url
|
||||
}
|
||||
def dumpProf(self, prof:domain.profile) -> dict:
|
||||
rawProf = {
|
||||
"server1":self.dumpServ(prof.server1),
|
||||
"name":prof.name
|
||||
}
|
||||
if prof.server2:
|
||||
rawProf['server2'] = self.dumpServ(prof.server2)
|
||||
return rawProf
|
||||
103
src/ui_components.py
Normal file
103
src/ui_components.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import gi
|
||||
from typing import Optional, Callable
|
||||
from gi.repository import Gtk, Adw
|
||||
from models import DNSProfile, DNSServer, DNSType
|
||||
from proxy_manager import ProxyManager
|
||||
|
||||
class SettingsPage(Adw.PreferencesPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.proxy_manager = ProxyManager()
|
||||
self._build_ui()
|
||||
self._load_settings()
|
||||
|
||||
def _build_ui(self):
|
||||
self.set_title("Settings")
|
||||
group = Adw.PreferencesGroup(title="Legacy Proxy Configuration", description="Reads system-wide proxy settings. This feature is read-only.")
|
||||
self.add(group)
|
||||
self.host_row = Adw.EntryRow(title="Proxy Host", editable=False)
|
||||
self.port_row = Adw.EntryRow(title="Proxy Port", editable=False)
|
||||
group.add(self.host_row)
|
||||
group.add(self.port_row)
|
||||
|
||||
def _load_settings(self):
|
||||
settings = self.proxy_manager.get_proxy_settings()
|
||||
if settings['enabled']:
|
||||
self.host_row.set_text(settings['host'])
|
||||
self.port_row.set_text(str(settings['port']))
|
||||
else:
|
||||
self.host_row.set_text("Proxy is disabled or not configured")
|
||||
self.port_row.set_text("")
|
||||
|
||||
class ProfileRow(Adw.ActionRow):
|
||||
def __init__(self, profile: DNSProfile, on_activate: Callable, on_edit: Callable, on_delete: Callable):
|
||||
super().__init__()
|
||||
self.profile = profile
|
||||
self.set_title(profile.name)
|
||||
self.set_subtitle(" • ".join([s.primary + (f", {s.secondary}" if s.secondary else "") for s in profile.servers]))
|
||||
|
||||
self.switch = Gtk.Switch(valign=Gtk.Align.CENTER)
|
||||
self.switch.connect('state-set', lambda _, state: on_activate(self.profile, state))
|
||||
self.add_suffix(self.switch)
|
||||
|
||||
edit_button = Gtk.Button(icon_name="edit-symbolic", valign=Gtk.Align.CENTER, css_classes=["flat"])
|
||||
edit_button.connect('clicked', lambda _: on_edit(self.profile))
|
||||
self.add_suffix(edit_button)
|
||||
|
||||
delete_button = Gtk.Button(icon_name="user-trash-symbolic", valign=Gtk.Align.CENTER, css_classes=["flat", "error"])
|
||||
delete_button.connect('clicked', lambda _: on_delete(self.profile))
|
||||
self.add_suffix(delete_button)
|
||||
|
||||
def set_active(self, active: bool):
|
||||
with self.switch.freeze_notify():
|
||||
self.switch.set_active(active)
|
||||
|
||||
class ProfileEditSheet(Gtk.Box):
|
||||
def __init__(self, profile: Optional[DNSProfile] = None):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.profile = profile
|
||||
self._build_form()
|
||||
if profile:
|
||||
self._populate_fields()
|
||||
|
||||
def _build_form(self):
|
||||
clamp = Adw.Clamp(maximum_size=400, tightening_threshold=300)
|
||||
self.append(clamp)
|
||||
page = Adw.PreferencesPage(margin_top=12, margin_bottom=12, margin_start=12, margin_end=12)
|
||||
clamp.set_child(page)
|
||||
|
||||
group = Adw.PreferencesGroup()
|
||||
page.add(group)
|
||||
self.name_row = Adw.EntryRow(title="Profile Name")
|
||||
self.primary_row = Adw.EntryRow(title="Primary DNS")
|
||||
self.secondary_row = Adw.EntryRow(title="Secondary DNS (Optional)")
|
||||
self.description_row = Adw.EntryRow(title="Description (Optional)")
|
||||
type_model = Gtk.StringList.new(["Standard DNS", "DNS over HTTPS (DoH)", "DNS over TLS (DoT)"])
|
||||
self.type_row = Adw.ComboRow(title="DNS Type", model=type_model)
|
||||
self.type_row.connect('notify::selected', lambda *_: self.doh_row.set_visible(self.type_row.get_selected() == 1))
|
||||
self.doh_row = Adw.EntryRow(title="DoH URL", visible=False)
|
||||
for row in [self.name_row, self.type_row, self.primary_row, self.secondary_row, self.doh_row, self.description_row]:
|
||||
group.add(row)
|
||||
|
||||
def _populate_fields(self):
|
||||
self.name_row.set_text(self.profile.name)
|
||||
if self.profile.servers:
|
||||
s = self.profile.servers[0]
|
||||
self.primary_row.set_text(s.primary)
|
||||
self.secondary_row.set_text(s.secondary or "")
|
||||
self.description_row.set_text(s.description or "")
|
||||
self.doh_row.set_text(s.doh_url or "")
|
||||
self.type_row.set_selected({DNSType.STANDARD: 0, DNSType.DOH: 1, DNSType.DOT: 2}.get(s.dns_type, 0))
|
||||
|
||||
def get_profile(self) -> Optional[DNSProfile]:
|
||||
name = self.name_row.get_text().strip()
|
||||
primary = self.primary_row.get_text().strip()
|
||||
if not name or not primary:
|
||||
return None
|
||||
dns_type = [DNSType.STANDARD, DNSType.DOH, DNSType.DOT][self.type_row.get_selected()]
|
||||
server = DNSServer(name=name, primary=primary,
|
||||
secondary=self.secondary_row.get_text().strip() or None,
|
||||
description=self.description_row.get_text().strip() or None,
|
||||
dns_type=dns_type,
|
||||
doh_url=self.doh_row.get_text().strip() if dns_type == DNSType.DOH else None)
|
||||
return DNSProfile(name, [server])
|
||||
@@ -1,443 +0,0 @@
|
||||
# In the name of god
|
||||
|
||||
from gi.repository import Gdk, Gtk
|
||||
import settings
|
||||
import domain as domain
|
||||
from pathlib import Path
|
||||
from .textCheck import isIp,baseCheck
|
||||
|
||||
class trashIcon(Gtk.Image):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_from_icon_name("trash-empty")
|
||||
# self.set_size_request(15,15)
|
||||
|
||||
class pencilIcon(Gtk.Image):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.set_from_icon_name("draw-freehand")
|
||||
|
||||
class profileConnectButton(Gtk.Switch):
|
||||
def __init__(self, app:domain.app, profile:domain.profile, default=False):
|
||||
super().__init__()
|
||||
self.app = app
|
||||
self.domain = profile
|
||||
self.add_css_class('server-button')
|
||||
self.set_state(default)
|
||||
self.connect("state-set", self.onChange)
|
||||
def onChange(self, _, state):
|
||||
if state:
|
||||
self.app.connectProfile(self.domain)
|
||||
else:
|
||||
self.app.disconnectProfiles()
|
||||
|
||||
|
||||
class Label(Gtk.Label):
|
||||
def __init__(self,label,classes=[],maxChar=None,**more):
|
||||
if maxChar:
|
||||
if label.__len__() > maxChar:
|
||||
if maxChar > 2 :
|
||||
label = label[:maxChar-2] + ".."
|
||||
else :
|
||||
label = label[:maxChar]
|
||||
super().__init__(label=label,**more)
|
||||
self.set_css_classes(classes+['label'])
|
||||
|
||||
class LeftFloat(Gtk.AspectFrame):
|
||||
def __init__(self,widget):
|
||||
super().__init__(
|
||||
halign=Gtk.Align.START,
|
||||
obey_child=True
|
||||
)
|
||||
self.set_child(widget)
|
||||
|
||||
class centerFloat(Gtk.AspectFrame):
|
||||
def __init__(self,widget):
|
||||
super().__init__(
|
||||
halign=Gtk.Align.CENTER,
|
||||
xalign=0.5,
|
||||
yalign=0.5,
|
||||
# ratio=1.0,
|
||||
obey_child=True
|
||||
)
|
||||
self.set_child(widget)
|
||||
|
||||
class Fixed(Gtk.Fixed):
|
||||
def __init__(self,classes=[]):
|
||||
super().__init__()
|
||||
self.set_css_classes(classes)
|
||||
def put(self, widget, x, y):
|
||||
super().put(widget, x, y)
|
||||
return self
|
||||
|
||||
class profileInformation(Gtk.Fixed):
|
||||
def __init__(self,profile:domain.profile):
|
||||
super().__init__()
|
||||
self.prof = profile
|
||||
self.update()
|
||||
self.add_css_class('server-information')
|
||||
def update(self):
|
||||
try:
|
||||
self.remove(self.title)
|
||||
self.title = None
|
||||
self.remove(self.inform)
|
||||
self.inform = None
|
||||
except:...
|
||||
|
||||
self.title = Label(
|
||||
self.prof.name,
|
||||
classes=[
|
||||
'color-high',
|
||||
'font-l'
|
||||
],
|
||||
maxChar=11
|
||||
)
|
||||
serversAspectFrame = Gtk.AspectFrame()
|
||||
serversAspectFrame.add_css_class('server-aspect-frame')
|
||||
self.servers = Grid(
|
||||
classes=[
|
||||
'ml-abit'
|
||||
]
|
||||
)
|
||||
serversAspectFrame.set_child(
|
||||
self.servers
|
||||
)
|
||||
servers = [self.prof.server1]
|
||||
if self.prof.server2: servers.append(self.prof.server2)
|
||||
for i, serv in enumerate(servers):
|
||||
self.servers.attach(
|
||||
LeftFloat(
|
||||
Label(
|
||||
f'{i+1}: {str(serv)}',
|
||||
classes=['font-s','color-low'],
|
||||
maxChar=11
|
||||
)
|
||||
),0,i,1,1
|
||||
)
|
||||
self.put(
|
||||
self.title,
|
||||
0,5
|
||||
)
|
||||
self.put(
|
||||
serversAspectFrame,
|
||||
80,0
|
||||
)
|
||||
|
||||
class Button(Gtk.Button):
|
||||
def __init__(self, child, onClick=lambda*_:... , classes=[],size=None,enable=True):
|
||||
super().__init__()
|
||||
self.set_child(child)
|
||||
self.set_css_classes(['button'] + classes)
|
||||
# self.set_relief(Gtk.ReliefStyle.NONE)
|
||||
if size:
|
||||
self.set_size_request(*size)
|
||||
self.set_hexpand(False)
|
||||
self.set_vexpand(False)
|
||||
self.connect('clicked', onClick)
|
||||
self.set_sensitive(enable)
|
||||
def enable_css(self, className):
|
||||
if className not in self.get_css_classes():
|
||||
self.add_css_class(className)
|
||||
def disable_css(self, className):
|
||||
while className in self.get_css_classes():
|
||||
self.remove_css_class(className)
|
||||
def enable(self):
|
||||
self.set_sensitive(True)
|
||||
def disable(self):
|
||||
self.set_sensitive(False)
|
||||
|
||||
class profile(Gtk.Box):
|
||||
def __init__(self,app:domain.app,profile:domain.profile):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.add_css_class('server')
|
||||
self.fixed = Gtk.Fixed()
|
||||
self.app = app
|
||||
self.domain = profile
|
||||
self.append(
|
||||
self.fixed
|
||||
)
|
||||
self.connectButton = profileConnectButton(app,profile)
|
||||
self.fixed.put(
|
||||
self.connectButton,
|
||||
235,4
|
||||
)
|
||||
self.fixed.put(
|
||||
Button(
|
||||
trashIcon(),lambda *_:self.app.deleteProfile(profile),[]
|
||||
)
|
||||
,
|
||||
189,0
|
||||
)
|
||||
self.fixed.put(
|
||||
Button(
|
||||
pencilIcon(),lambda *_ : self.app.window.goEdit(profile,lambda newp : self.app.editProfile(profile, self.app.window.editor.dumpProfToInput(newp)) ),[]
|
||||
)
|
||||
,
|
||||
150,0
|
||||
)
|
||||
self.fixed.put(
|
||||
profileInformation(profile),
|
||||
10,0
|
||||
)
|
||||
self.update()
|
||||
def update(self):
|
||||
self.connectButton.set_state(
|
||||
self.app.connectedProfile == self.domain
|
||||
)
|
||||
|
||||
class Grid(Gtk.Grid):
|
||||
def __init__(self,*args,classes=[],**wargs):
|
||||
super().__init__(*args,**wargs)
|
||||
self.set_css_classes(classes)
|
||||
def empty (self):
|
||||
children = self.get_children()
|
||||
for child in children:
|
||||
self.remove(child)
|
||||
|
||||
class profileList(Grid):
|
||||
def __init__(self, app:domain.app):
|
||||
super().__init__()
|
||||
self.add_css_class('server-list')
|
||||
self.app = app
|
||||
self.profs = []
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
for i, p in enumerate(self.app.data.profiles):
|
||||
prof = profile(self.app,p)
|
||||
self.profs.append(prof)
|
||||
self.attach(
|
||||
prof,
|
||||
0,i,1,1
|
||||
)
|
||||
|
||||
def restart(self):
|
||||
self.remove_column(0)
|
||||
self.profs = []
|
||||
self.start()
|
||||
|
||||
def update(self):
|
||||
for current in self.profs:
|
||||
if current.domain not in self.app.data.profiles:
|
||||
self.restart()
|
||||
for featue in self.app.data.profiles:
|
||||
if featue not in list(map(lambda x:x.domain, self.profs)):
|
||||
self.restart()
|
||||
for ch in self.profs:
|
||||
ch.update()
|
||||
|
||||
class addButton(Gtk.Overlay):
|
||||
def __init__(self, app):
|
||||
super().__init__()
|
||||
self.app = app
|
||||
self.button = Button(Label('+'),self.click)
|
||||
self.button.set_css_classes(['add-btn'])
|
||||
self.set_child(self.button)
|
||||
def click(self, *_):
|
||||
self.app.window.goEdit(None, lambda data: self.app.addProfile(
|
||||
domain.profile(
|
||||
server1=domain.server(url=data['server1'],type='dns'),
|
||||
server2=domain.server(url=data['server2'],type='dns') if data.get('server2',False) else None,
|
||||
name=data['name']
|
||||
)
|
||||
))
|
||||
|
||||
class mainWindowContainer(Gtk.Fixed):
|
||||
def __init__(self, app):
|
||||
super().__init__()
|
||||
self.childs = [profileList(app)]
|
||||
self.put(
|
||||
self.childs[0],0,0
|
||||
)
|
||||
self.put(
|
||||
addButton(app),10,350
|
||||
)
|
||||
def update(self):
|
||||
for ch in self.childs:
|
||||
ch.update()
|
||||
|
||||
|
||||
class Input(Gtk.Entry):
|
||||
def __init__(self,app=None,name="",optional=False,placeholder=None,classes=[],onchange=None,checker=lambda *_:True):
|
||||
super().__init__()
|
||||
self.set_size_request(250,20)
|
||||
self.app = app
|
||||
self.set_css_classes(['input'] + classes)
|
||||
self.name = name
|
||||
self.optional = optional
|
||||
if placeholder:
|
||||
self.set_placeholder_text(placeholder)
|
||||
if onchange:
|
||||
self.connect("changed", onchange)
|
||||
self.checker = checker
|
||||
|
||||
class Form:
|
||||
def __init__(self, *inputs:list[Input]) -> None:
|
||||
self.inputs = inputs
|
||||
class LowEntryError(Exception):
|
||||
def __init__(self, *entries):
|
||||
super().__init__()
|
||||
self.inputs = entries
|
||||
def read(self):
|
||||
empties = []
|
||||
for i in self.inputs:
|
||||
i:Input
|
||||
t:str = i.get_text()
|
||||
if t.strip() == '':
|
||||
if not i.optional:
|
||||
empties.append(i)
|
||||
else:
|
||||
if not i.checker(t):
|
||||
empties.append(i)
|
||||
|
||||
if empties:
|
||||
raise self.LowEntryError(*empties)
|
||||
return {
|
||||
i.name: i.get_text() for i in self.inputs
|
||||
}
|
||||
def write(self, data:dict):
|
||||
for inp in self.inputs:
|
||||
try:
|
||||
if not data:
|
||||
inp.set_text("")
|
||||
elif inp.name in data:
|
||||
inp.set_text(data[inp.name])
|
||||
|
||||
except:...
|
||||
|
||||
class editWindowContainer(Gtk.Box):
|
||||
def handleOK (self, *args):
|
||||
# self.then(self.data)
|
||||
self.app.window.goMain()
|
||||
def set_entries(self, baseData ,then):
|
||||
self.form.write(baseData)
|
||||
self.then = then
|
||||
def __init__(self,app):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.app = app
|
||||
self.inputs = [
|
||||
Input(self.app, "name", onchange=self.update, classes=['mt-6'], placeholder="Name"),
|
||||
Input(self.app, "server1",onchange=self.update, classes=['mt-2'], checker=isIp(),placeholder="Server 1"),
|
||||
Input(self.app, "server2",onchange=self.update, classes=['mt-2'], checker=isIp(),placeholder="Server 2 (optional)", optional=True)
|
||||
]
|
||||
self.form = Form(*self.inputs)
|
||||
for i in self.inputs : self.append(centerFloat(i))
|
||||
self.okbtn = Button(Label("Ok"),self.ok, classes=['mb-1'], enable=False)
|
||||
self.canbtn = Button(Label("Cancel"),self.cancel, classes=['mb-1', 'bg-red'])
|
||||
self.append(
|
||||
centerFloat(
|
||||
Fixed(classes=['mt-3'])
|
||||
.put(
|
||||
self.okbtn, 10, 0
|
||||
)
|
||||
.put(
|
||||
self.canbtn, 60, 0
|
||||
)
|
||||
)
|
||||
)
|
||||
def update(self,*_):
|
||||
try:
|
||||
data = self.form.read()
|
||||
self.okbtn.enable()
|
||||
self.okbtn.enable_css("bg-green")
|
||||
except Form.LowEntryError as e:
|
||||
self.okbtn.disable()
|
||||
self.okbtn.disable_css("bg-green")
|
||||
def ok(self,*_):
|
||||
try:
|
||||
data = self.form.read()
|
||||
if self.then:
|
||||
self.then(data)
|
||||
except:
|
||||
...
|
||||
self.set_entries(None, None)
|
||||
self.back()
|
||||
|
||||
def cancel(self,*_):
|
||||
self.set_entries(None, None)
|
||||
self.back()
|
||||
|
||||
def back(self):
|
||||
self.app.window.goMain()
|
||||
|
||||
def loadProfFromInput(self, prof):
|
||||
r = {
|
||||
"name":prof.name,
|
||||
"server1":prof.server1.url
|
||||
}
|
||||
if prof.server2:
|
||||
r['server2'] = prof.server2.url
|
||||
return r
|
||||
def dumpProfToInput(self,prof):
|
||||
wargs = {
|
||||
"name":prof['name'],
|
||||
"server1":self.app.loadServ({'url':prof['server1'],'type':'dns'})
|
||||
}
|
||||
if prof.get('server2',False).strip():
|
||||
wargs['server2'] = self.app.loadServ({'url':prof['server2'],'type':'dns'})
|
||||
return domain.profile(**wargs)
|
||||
|
||||
|
||||
|
||||
class Stack(Gtk.Stack):
|
||||
def __init__(self,**data):
|
||||
super().__init__()
|
||||
self.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
|
||||
self.data = data
|
||||
self.add(**data)
|
||||
def show(self,name):
|
||||
self.set_visible_child(
|
||||
self.data[name]
|
||||
)
|
||||
def add(self,**data):
|
||||
self.data = {
|
||||
**self.data,
|
||||
**data
|
||||
}
|
||||
for _, widget in data.items():
|
||||
Gtk.Stack.add_child(self,widget)
|
||||
|
||||
import time
|
||||
class window(Gtk.ApplicationWindow):
|
||||
def __init__(self, app=None):
|
||||
while not Gtk.init_check():
|
||||
time.sleep(0.1)
|
||||
super().__init__(application=app, title=settings.WINDOW_TITLE)
|
||||
app.window = self
|
||||
self.app = app
|
||||
self.set_default_size(300,400)
|
||||
self.main = mainWindowContainer(app)
|
||||
self.editor = editWindowContainer(app)
|
||||
self.stack = Stack(
|
||||
main = self.main,
|
||||
editor = self.editor
|
||||
)
|
||||
self.set_child(
|
||||
self.stack
|
||||
)
|
||||
cssProvider = Gtk.CssProvider()
|
||||
cssProvider.load_from_path(
|
||||
str(Path(
|
||||
__file__
|
||||
).parent / "style.css"
|
||||
)
|
||||
)
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
Gdk.Display.get_default(),
|
||||
cssProvider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
)
|
||||
self.goMain()
|
||||
def update(self):
|
||||
self.main.update()
|
||||
def goMain(self):
|
||||
self.update()
|
||||
self.stack.show('main')
|
||||
def goEdit(self, baseData=None, then=None):
|
||||
if isinstance(baseData,domain.profile):
|
||||
baseData = self.editor.loadProfFromInput(baseData)
|
||||
self.editor.set_entries(baseData, then)
|
||||
self.stack.show('editor')
|
||||
__all__ = [
|
||||
window
|
||||
]
|
||||
@@ -1,91 +0,0 @@
|
||||
.server{
|
||||
border-radius: 10px;
|
||||
border: 1px solid #666 ;
|
||||
color: aqua;
|
||||
margin-top: 3px;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.color-aqua{
|
||||
color: #d54;
|
||||
}
|
||||
.font-l{
|
||||
font-size: 16px;
|
||||
}
|
||||
.font-s{
|
||||
font-size: 11px;
|
||||
}
|
||||
.color-high{
|
||||
color:#ccc;
|
||||
}
|
||||
.color-low{
|
||||
color: #666;
|
||||
}
|
||||
.server-button{
|
||||
margin-right: 5px;
|
||||
}
|
||||
.server-list{
|
||||
margin-left: 4px;
|
||||
}
|
||||
.input{
|
||||
border-radius: 10px;
|
||||
}
|
||||
.server-informaion{
|
||||
|
||||
}
|
||||
.add-btn{
|
||||
font-size: 20px;
|
||||
border-radius: 15px;
|
||||
min-width: 20px;
|
||||
min-height: 30px;
|
||||
}
|
||||
.server-aspect-frame{
|
||||
min-height: 34px;
|
||||
}
|
||||
.trash-btn{
|
||||
|
||||
}
|
||||
.button{
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.text-lg{
|
||||
font: 16px bolder;
|
||||
|
||||
}
|
||||
.titlebar{
|
||||
/* background-color: aqua; */
|
||||
}
|
||||
.mt-1{
|
||||
margin-top: 5px;
|
||||
}
|
||||
.mt-2{
|
||||
margin-top: 10px;
|
||||
}
|
||||
.mt-3{
|
||||
margin-top: 15px;
|
||||
}
|
||||
.mt-6{
|
||||
margin-top: 30px;
|
||||
}
|
||||
.mb-1{
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.ml-abit{
|
||||
margin-left: 3px;
|
||||
}
|
||||
.bg-red{
|
||||
/* background-color: #d54; */
|
||||
background: rgb(250, 20, 20);
|
||||
color: azure;
|
||||
}
|
||||
.bg-red:hover{
|
||||
background: rgb(250, 100, 100);
|
||||
}
|
||||
.bg-green{
|
||||
background: rgba(53, 167, 8, 0.993);
|
||||
color: azure;
|
||||
}
|
||||
.bg-green:hover{
|
||||
background: rgb(125, 255, 73);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
class baseCheck:
|
||||
def check(self,text):
|
||||
return False
|
||||
def __call__(self, text):
|
||||
return self.check(text)
|
||||
|
||||
class regexCheck(baseCheck):
|
||||
pattern = ""
|
||||
def check(self,check):
|
||||
import re
|
||||
if re.match(self.pattern, check):
|
||||
return True
|
||||
return False
|
||||
|
||||
class isIp(regexCheck):
|
||||
pattern = r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$"
|
||||
Reference in New Issue
Block a user