diff --git a/.streamlit/config.toml b/.streamlit/config.toml index e9e9e5e..76ca72a 100644 --- a/.streamlit/config.toml +++ b/.streamlit/config.toml @@ -5,3 +5,7 @@ backgroundColor = "#ffffff" secondaryBackgroundColor = "#f0f2f6" textColor = "#1a1a1a" font = "sans serif" + +[server] +address = "192.168.2.115" +port = 8501 diff --git a/__pycache__/view_settings.cpython-314.pyc b/__pycache__/view_settings.cpython-314.pyc index 340374f..37a8c0b 100644 Binary files a/__pycache__/view_settings.cpython-314.pyc and b/__pycache__/view_settings.cpython-314.pyc differ diff --git a/view_settings.py b/view_settings.py index b5342cf..74f8860 100644 --- a/view_settings.py +++ b/view_settings.py @@ -10,6 +10,104 @@ import os, re CONFIG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".streamlit") CONFIG_FILE = os.path.join(CONFIG_DIR, "config.toml") +GITHUB_REPO = "alphapapi-ctrl/mt5-tools" +GITHUB_BRANCH = "master" + + +# ───────────────────────────────────────────────────────────────────────────── +# Version / update check +# ───────────────────────────────────────────────────────────────────────────── +def _get_local_sha() -> str: + """Return the current local git HEAD SHA, or '' if not a git repo.""" + try: + git_head = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".git", "HEAD") + if not os.path.isfile(git_head): + return "" + ref = open(git_head).read().strip() + if ref.startswith("ref:"): + # Resolve the ref to a SHA + ref_path = ref.split(" ", 1)[1] # e.g. refs/heads/master + sha_file = os.path.join( + os.path.dirname(os.path.abspath(__file__)), ".git", ref_path + ) + if os.path.isfile(sha_file): + return open(sha_file).read().strip() + else: + return ref # detached HEAD — ref is the SHA directly + except Exception: + pass + return "" + + +@st.cache_data(ttl=3600) # cache for 1 hour so we don't hammer the API +def _get_remote_info() -> dict: + """ + Fetch the latest commit SHA and message from GitHub API. + Returns dict with keys: sha, message, author, date, error + """ + import urllib.request, json + url = f"https://api.github.com/repos/{GITHUB_REPO}/commits/{GITHUB_BRANCH}" + try: + req = urllib.request.Request(url, headers={"User-Agent": "MT5Tools"}) + with urllib.request.urlopen(req, timeout=5) as r: + data = json.loads(r.read().decode()) + return { + "sha": data["sha"], + "short": data["sha"][:7], + "message": data["commit"]["message"].split("\n")[0], + "author": data["commit"]["author"]["name"], + "date": data["commit"]["author"]["date"][:10], + "error": None, + } + except Exception as e: + return {"sha": "", "short": "", "message": "", "author": "", "date": "", "error": str(e)} + + +@st.cache_data(ttl=3600) +def _get_recent_commits(n=10) -> list: + """Fetch the last n commits for the changelog.""" + import urllib.request, json + url = f"https://api.github.com/repos/{GITHUB_REPO}/commits?sha={GITHUB_BRANCH}&per_page={n}" + try: + req = urllib.request.Request(url, headers={"User-Agent": "MT5Tools"}) + with urllib.request.urlopen(req, timeout=5) as r: + data = json.loads(r.read().decode()) + return [ + { + "sha": c["sha"][:7], + "message": c["commit"]["message"], + "date": c["commit"]["author"]["date"][:10], + "author": c["commit"]["author"]["name"], + } + for c in data + ] + except Exception: + return [] + + +def check_for_update_banner(): + """ + Call from app.py on every page render. + Shows a top-of-page banner if a newer version is available on GitHub. + Silently does nothing if offline or not a git repo. + """ + local_sha = _get_local_sha() + if not local_sha: + return # not a git repo — skip silently + + remote = _get_remote_info() + if remote["error"] or not remote["sha"]: + return # offline or API error — skip silently + + if not remote["sha"].startswith(local_sha) and local_sha != remote["sha"]: + st.info( + f"🔄 **Update available** — " + f"latest commit `{remote['short']}` on {remote['date']}: " + f"*{remote['message']}* · " + f"Run `git pull` then restart to update.", + icon="🔄", + ) + THEMES = { "Dark": { "base": "dark", @@ -47,15 +145,77 @@ def _read_config() -> dict: return dict(THEMES["Dark"]) +def _get_lan_ip() -> str: + """Return the first 192.168.x.x address found on this machine, or '' if none.""" + import socket + try: + # Get all addresses for this hostname + hostname = socket.gethostname() + addrs = socket.getaddrinfo(hostname, None) + for addr in addrs: + ip = addr[4][0] + if ip.startswith("192.168."): + return ip + except Exception: + pass + # Fallback: connect to a dummy address to get the outbound LAN interface + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("192.168.1.1", 80)) + ip = s.getsockname()[0] + s.close() + if ip.startswith("192.168."): + return ip + except Exception: + pass + return "" + + +def _read_server_config() -> dict: + """Read [server] section from config.toml.""" + if not os.path.isfile(CONFIG_FILE): + return {} + try: + text = open(CONFIG_FILE).read() + found = {} + m = re.search(r'address\s*=\s*"([^"]*)"', text) + if m: found["address"] = m.group(1) + m = re.search(r'port\s*=\s*(\d+)', text) + if m: found["port"] = int(m.group(1)) + return found + except Exception: + return {} + + def _write_config(theme: dict): os.makedirs(CONFIG_DIR, exist_ok=True) + # Preserve existing [server] section if present + server_block = "" + if os.path.isfile(CONFIG_FILE): + existing = open(CONFIG_FILE).read() + m = re.search(r'\[server\].*?(?=\[|\Z)', existing, re.DOTALL) + if m: + server_block = "\n" + m.group(0).strip() + "\n" lines = ["[theme]\n"] for k, v in theme.items(): lines.append(f'{k} = "{v}"\n') + if server_block: + lines.append(server_block) with open(CONFIG_FILE, "w") as f: f.writelines(lines) +def _write_server_config(address: str, port: int): + """Write or update the [server] section in config.toml.""" + os.makedirs(CONFIG_DIR, exist_ok=True) + # Read existing content, strip any existing [server] block + existing = open(CONFIG_FILE).read() if os.path.isfile(CONFIG_FILE) else "" + existing = re.sub(r'\[server\].*?(?=\[|\Z)', '', existing, flags=re.DOTALL).strip() + server_block = f'\n\n[server]\naddress = "{address}"\nport = {port}\n' + with open(CONFIG_FILE, "w") as f: + f.write(existing + server_block) + + def _is_custom(cfg: dict) -> bool: """Return True if the saved config differs from both standard themes.""" for t in THEMES.values(): @@ -244,6 +404,182 @@ def render(): horizontal=True, key="st_font_size") inject_theme_css() + # ── Network ─────────────────────────────────────────────────────────────── + st.divider() + st.subheader("Network Access") + st.caption("Controls which network interface Streamlit binds to. " + "Restart the app after changing.") + + server_cfg = _read_server_config() + lan_ip = _get_lan_ip() + + nc1, nc2 = st.columns(2) + access_mode = nc1.radio( + "Access", + ["Localhost only", "Local network (LAN)"], + horizontal=True, + key="st_net_mode", + index=1 if server_cfg.get("address","") not in ("","127.0.0.1","localhost") else 0, + help="Localhost = this PC only. LAN = all devices on your 192.168.x.x network.", + ) + port = nc2.number_input( + "Port", min_value=1024, max_value=65535, + value=server_cfg.get("port", 8501), + step=1, key="st_net_port", + ) + + if access_mode == "Local network (LAN)": + if lan_ip: + st.info(f"Detected LAN address: **{lan_ip}** → " + f"other devices can reach the app at `http://{lan_ip}:{int(port)}`") + else: + st.warning("No 192.168.x.x address found — are you connected to your router?") + + # Hostname instructions + import socket as _socket, platform as _platform + hostname = _socket.gethostname().lower() + os_name = _platform.system() + lan_ip_str = lan_ip or "192.168.x.x" + + st.markdown("**Use a custom hostname instead of an IP address**") + st.caption( + "Add an entry to the hosts file on each device that needs to access the app. " + "No extra software required — works on Windows, macOS, iOS, and Android." + ) + + custom_name = st.text_input( + "Hostname to use", value="mt5tools", + key="st_custom_hostname", + help="e.g. mt5tools → http://mt5tools:8501" + ) + st.markdown( + f"Once set, devices on your LAN can access the app at: " + f"`http://{custom_name}:{int(port)}`" + ) + + if os_name == "Windows": + st.markdown(f""" +
+Windows — edit the hosts file on each client device + +1. Open **Notepad as Administrator** (right-click Notepad → Run as administrator) +2. Open the file: `C:\\Windows\\System32\\drivers\\etc\\hosts` +3. Add this line at the bottom: + +``` +{lan_ip_str} {custom_name} +``` + +4. Save the file +5. Open a browser and go to `http://{custom_name}:{int(port)}` + +**Note:** You need to do this on every Windows device that will access the app. +No restart required — the change takes effect immediately. +
+""", unsafe_allow_html=True) + + elif os_name == "Darwin": + st.markdown(f""" +
+macOS — edit /etc/hosts on each client device + +1. Open **Terminal** +2. Run: + +``` +sudo nano /etc/hosts +``` + +3. Add this line at the bottom: + +``` +{lan_ip_str} {custom_name} +``` + +4. Press `Ctrl+X`, then `Y`, then `Enter` to save +5. Flush the DNS cache: + +``` +sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder +``` + +6. Open a browser and go to `http://{custom_name}:{int(port)}` +
+""", unsafe_allow_html=True) + + st.markdown(f""" +
+iOS / Android — not supported via hosts file + +Mobile devices do not allow editing the hosts file without rooting/jailbreaking. +Use the IP address directly instead: + +`http://{lan_ip_str}:{int(port)}` + +Alternatively, set a **static DHCP reservation** on your router so the IP never +changes — then bookmark the IP address URL. +
+""", unsafe_allow_html=True) + + if st.button("Apply Network Settings", key="st_net_apply"): + if access_mode == "Localhost only": + _write_server_config("127.0.0.1", int(port)) + else: + addr = lan_ip if lan_ip else "192.168.1.1" + _write_server_config(addr, int(port)) + st.success("Saved to config.toml — restart the app for changes to take effect.") + + # ── Version & Changelog ─────────────────────────────────────────────────── + st.divider() + st.subheader("Version & Changelog") + + local_sha = _get_local_sha() + remote = _get_remote_info() + + vc1, vc2 = st.columns(2) + vc1.markdown( + f"**Local version:** `{local_sha[:7] if local_sha else 'unknown'}`" + if local_sha else "**Local version:** not a git repo" + ) + if remote["error"]: + vc2.markdown(f"**Remote:** unable to reach GitHub *(offline?)*") + else: + vc2.markdown(f"**Latest on GitHub:** `{remote['short']}` — {remote['date']}") + + if local_sha and not remote["error"]: + if remote["sha"].startswith(local_sha) or local_sha == remote["sha"]: + st.success("✅ You are on the latest version.") + else: + st.warning( + f"🔄 **Update available.** Latest: `{remote['short']}` — *{remote['message']}*\n\n" + f"Run the following to update:\n```\ngit pull\n```\nThen restart the app." + ) + + if st.button("🔁 Check again", key="st_ver_refresh"): + st.cache_data.clear() + st.rerun() + + st.markdown("##### Recent Changes") + commits = _get_recent_commits(10) + if commits: + for c in commits: + is_local = local_sha and c["sha"] == local_sha[:7] + lines = c["message"].strip().split("\n") + title = lines[0] + body = [l for l in lines[1:] if l.strip()] + marker = " ← **you are here**" if is_local else "" + with st.expander(f"`{c['sha']}` · {c['date']} · {title}{marker}"): + if body: + for l in body: + if l.strip().startswith("-"): + st.markdown(l.strip()) + elif l.strip(): + st.markdown(l.strip()) + else: + st.caption("No extended description.") + else: + st.caption("Changelog unavailable — check your internet connection.") + # ── About ───────────────────────────────────────────────────────────────── st.divider() st.subheader("About")