diff --git a/README.md b/README.md index 369d976..d4133ac 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,44 @@ A reactive POS inventory system for software engineers with a snack addiction. F - **Secure:** Hashed password authentication via Flask-Login. - **On device scanner:** Add and scan products from within your phone! +## 📦 Building the Desktop App (.exe) + +If you want to run SekiPOS as a standalone Windows application with its own embedded browser window, you need to compile it using PyInstaller. + +### 1. Prerequisites +You **must** use a stable Python release like **Python 3.11** or **3.12**. Pre-release versions (like 3.14) will fail to compile the PyWebView C# dependencies. + +Install the required build tools globally for your stable Python version: +```powershell +py -3.11 -m pip install -r requirements.txt pyinstaller +``` + +### 2. Prepare `app.py` +Before compiling, scroll to the absolute bottom of `app.py` and ensure the standalone runner is active. It should look like this: +```python +if __name__ == '__main__': + init_db() + + # For standalone desktop app with embedded browser, use + run_standalone() + + # For docker or traditional server deployment, comment out run_standalone() and uncomment the line below: + # socketio.run(app, host='0.0.0.0', port=5000, debug=True) +``` + +### 3. Compile +Run this exact command in your terminal. It includes the hidden SocketIO threads and bundles your web templates: +```powershell +py -3.11 -m PyInstaller --noconfirm --onedir --windowed --add-data "templates;templates/" --add-data "static;static/" --hidden-import "engineio.async_drivers.threading" --icon "static/favicon.png" app.py +``` + + +### 4. Post-Build +Your portable app will be generated inside the `dist\app` folder. +* To keep your existing inventory, copy your `db/pos_database.db` and `static/cache/` folders from your source code into the new `dist\app` folder before running the `.exe`. + +--- + ## 🐳 Docker Deployment (Server) Build and run the central inventory server: diff --git a/app.py b/app.py index c052598..c211c4d 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,5 @@ import os +import sys import sqlite3 import requests from flask import send_file @@ -12,6 +13,8 @@ import uuid from datetime import datetime import zipfile import io +import webview +import threading # from dotenv import load_dotenv @@ -20,16 +23,42 @@ import io # MP_ACCESS_TOKEN = os.getenv('MP_ACCESS_TOKEN') # MP_TERMINAL_ID = os.getenv('MP_TERMINAL_ID') -app = Flask(__name__) -app.config['SECRET_KEY'] = 'seki_super_secret_key_99' # Change this if you have actual friends -socketio = SocketIO(app, cors_allowed_origins="*") +# --- PATH HELPERS FOR PYINSTALLER --- +def get_bundled_path(relative_path): + """Path for read-only files packed inside the .exe (templates, static)""" + if getattr(sys, 'frozen', False): + base_path = sys._MEIPASS + else: + base_path = os.path.abspath(os.path.dirname(__file__)) + return os.path.join(base_path, relative_path) -# Auth Setup +def get_persistent_path(relative_path): + """Path for read/write files that must survive reboots (db, cache)""" + if getattr(sys, 'frozen', False): + base_path = os.path.dirname(sys.executable) + else: + base_path = os.path.abspath(os.path.dirname(__file__)) + return os.path.join(base_path, relative_path) + +# --- FLASK INIT --- +app = Flask( + __name__, + template_folder=get_bundled_path('templates'), + static_folder=get_bundled_path('static') +) +app.config['SECRET_KEY'] = 'seki_super_secret_key_99' # Change this if you have actual friends +socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading') + +# --- AUTH SETUP (Do not delete this) --- login_manager = LoginManager(app) login_manager.login_view = 'login' -DB_FILE = 'db/pos_database.db' -CACHE_DIR = 'static/cache' +# --- DIRECTORY SETUP --- +DB_DIR = get_persistent_path('db') +os.makedirs(DB_DIR, exist_ok=True) +DB_FILE = os.path.join(DB_DIR, "pos_database.db") + +CACHE_DIR = get_persistent_path(os.path.join('static', 'cache')) os.makedirs(CACHE_DIR, exist_ok=True) # --- MODELS --- @@ -753,6 +782,29 @@ def delete_gasto(gasto_id): # }) # return jsonify({"status": "ok"}), 200 +def start_server(): + # Use socketio.run instead of default app.run + socketio.run(app, host='127.0.0.1', port=5000) + +def run_standalone(): + t = threading.Thread(target=start_server) + t.daemon = True + t.start() + + # GIVE FLASK 2 SECONDS TO BOOT UP BEFORE OPENING THE BROWSER + time.sleep(2) + + webview.create_window('SekiPOS', 'http://127.0.0.1:5000', width=1366, height=768, resizable=True, fullscreen=False, min_size=(800, 600), maximized=True) + + # private_mode=False is the magic flag that allows localStorage to survive. + # It saves data to %APPDATA%\pywebview on Windows. + webview.start(private_mode=False) + if __name__ == '__main__': init_db() + + # For standalone desktop app with embedded browser, use + #run_standalone() + + # For docker or traditional server deployment, comment out run_standalone() and uncomment the line below: socketio.run(app, host='0.0.0.0', port=5000, debug=True) diff --git a/app.spec b/app.spec new file mode 100644 index 0000000..fe19c55 --- /dev/null +++ b/app.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['app.py'], + pathex=[], + binaries=[], + datas=[('templates', 'templates/'), ('static', 'static/')], + hiddenimports=['engineio.async_drivers.threading'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='app', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['static\\favicon.png'], +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='app', +) diff --git a/build/.gitignore b/build/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/build/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/dist/.gitignore b/dist/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/dist/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file