Python-based Windows process manager for interactive and immersive installations
"""
CustomTkinter Messagebox
Author: Akash Bora // w/ mod by Dylan
Version: 2.5.1
"""

import shared_utils
import customtkinter
from PIL import Image, ImageTk
import os
import sys
import time
from typing import Literal
import ctypes

class CTkMessagebox(customtkinter.CTkToplevel):
    ICONS = {
        "check": None,
        "cancel": None,
        "info": None,
        "question": None,
        "warning": None
    }
    ICON_BITMAP = {}
    def __init__(self,
                 master: any = None,
                 width: int = 400,
                 height: int = 200,
                 title: str = "CTkMessagebox",
                 message: str = "This is a CTkMessagebox!",
                 option_1: str = "OK",
                 option_2: str = None,
                 option_3: str = None,
                 options: list = [],
                 border_width: int = 1,
                 border_color: str = "default",
                 button_color: str = "default",
                 bg_color: str = "default",
                 fg_color: str = "default",
                 text_color: str = "default",
                 title_color: str = "default",
                 button_text_color: str = "default",
                 button_width: int = None,
                 button_height: int = None,
                 cancel_button_color: str = None,
                 cancel_button: str = None, # types: circle, cross or none
                 button_hover_color: str = "default",
                 icon: str = "info",
                 icon_size: tuple = None,
                 corner_radius: int = 15,
                 justify: str = "right",
                 font: tuple = None,
                 header: bool = False,
                 topmost: bool = True,
                 fade_in_duration: int = 0,
                 sound: bool = False,
                 option_focus: Literal[1, 2, 3] = None):
        
        super().__init__()
        
        
        self.master_window = master
     
        self.width = 250 if width<250 else width
        self.height = 150 if height<150 else  height

        self.after(10)

        # Calculate spawn based on screen dpi
        try:
            hdc = ctypes.windll.user32.GetDC(0)
            LOGPIXELSX = 88
            actual_dpi = ctypes.windll.gdi32.GetDeviceCaps(hdc, LOGPIXELSX)
            ctypes.windll.user32.ReleaseDC(0, hdc)
            self.scaling_factor = actual_dpi / 96.0  # 96 DPI is the standard DPI, so we divide the actual by 96 to get the scaling factor
        except Exception as e:
            print(e)
            self.scaling_factor = 1.0

        # Get screen width and height
        screen_width = master.winfo_screenwidth() * self.scaling_factor
        screen_height = master.winfo_screenheight() * self.scaling_factor

        # Calculate position x and y coordinates
        self.spawn_x = int((screen_width / 2) - (self.width * self.scaling_factor / 2))
        self.spawn_y = int((screen_height / 2) - (self.height * self.scaling_factor / 2))

        self.geometry(f"{self.width}x{self.height}+{self.spawn_x}+{self.spawn_y}")
        self.title(title)
        self.resizable(width=False, height=False)
        self.fade = fade_in_duration
        
        if self.fade:
            self.fade = 20 if self.fade<20 else self.fade
            self.attributes("-alpha", 0)
        
        if not header:
            self.overrideredirect(1)
            
        if topmost:
            self.attributes("-topmost", True)
        else:
            self.transient(self.master_window)
    
        if sys.platform.startswith("win"):
            self.transparent_color = self._apply_appearance_mode(self.cget("fg_color"))
            self.attributes("-transparentcolor", self.transparent_color)
            default_cancel_button = "cross"
        elif sys.platform.startswith("darwin"):
            self.transparent_color = 'systemTransparent'
            self.attributes("-transparent", True)
            default_cancel_button = "circle"
        else:
            self.transparent_color = '#000001'
            corner_radius = 0
            default_cancel_button = "cross"

        self.lift()
        
        self.config(background=self.transparent_color)
        self.protocol("WM_DELETE_WINDOW", self.button_event)
        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=1)    
        self.x = self.winfo_x()
        self.y = self.winfo_y()
        self._title = title
        self.message = message
        self.font = font
        self.justify = justify
        self.sound = sound
        self.cancel_button = cancel_button if cancel_button else default_cancel_button      
        self.round_corners = corner_radius if corner_radius<=30 else 30
        self.button_width = button_width if button_width else self.width/4
        self.button_height = button_height if button_height else 28
  
        if self.fade: self.attributes("-alpha", 0)
        
        if self.button_height>self.height/4: self.button_height = self.height/4 -20
        self.dot_color = cancel_button_color
        self.border_width = border_width if border_width<6 else 5
        
        if type(options) is list and len(options)>0:
            try:
                option_1 = options[-1]
                option_2 = options[-2]
                option_3 = options[-3]
            except IndexError: None
            
        if bg_color=="default":
            self.bg_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkFrame"]["fg_color"])
        else:
            self.bg_color = bg_color

        if fg_color=="default":
            self.fg_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkFrame"]["top_fg_color"])
        else:
            self.fg_color = fg_color
            
        default_button_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkButton"]["fg_color"])

        if sys.platform.startswith("win"):
            if self.bg_color==self.transparent_color or self.fg_color==self.transparent_color:
                self.configure(fg_color="#000001")
                self.transparent_color = "#000001"
                self.attributes("-transparentcolor", self.transparent_color)

        if button_color=="default":
            self.button_color = (default_button_color, default_button_color, default_button_color)
        else:
            if type(button_color) is tuple:
                if len(button_color)==2:                
                    self.button_color = (button_color[0], button_color[1], default_button_color)
                elif len(button_color)==1:
                    self.button_color = (button_color[0], default_button_color, default_button_color)
                else:
                    self.button_color = button_color
            else:
                self.button_color = (button_color, button_color, button_color)

        if text_color=="default":
            self.text_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkLabel"]["text_color"])
        else:
            self.text_color = text_color

        if title_color=="default":
            self.title_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkLabel"]["text_color"])
        else:
            self.title_color = title_color
            
        if button_text_color=="default":
            self.bt_text_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkButton"]["text_color"])
        else:
            self.bt_text_color = button_text_color

        if button_hover_color=="default":
            self.bt_hv_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkButton"]["hover_color"])
        else:
            self.bt_hv_color = button_hover_color
            
        if border_color=="default":
            self.border_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkFrame"]["border_color"])
        else:
            self.border_color = border_color
            
        if icon_size:
            self.size_height = icon_size[1] if icon_size[1]<=self.height-100 else self.height-100
            self.size = (icon_size[0], self.size_height)
        else:
            self.size = (self.height/4, self.height/4)
        
        self.icon = self.load_icon(icon, icon_size) if icon else None
        
        self.frame_top = customtkinter.CTkFrame(self, corner_radius=self.round_corners, width=self.width, border_width=self.border_width,
                                                bg_color=self.transparent_color, fg_color=self.bg_color, border_color=self.border_color)
        self.frame_top.grid(sticky="nswe")

        if button_width:
            self.frame_top.grid_columnconfigure(0, weight=1)
        else:
            self.frame_top.grid_columnconfigure((1,2,3), weight=1)

        if button_height:
            self.frame_top.grid_rowconfigure((0,1,3), weight=1)
        else:
            self.frame_top.grid_rowconfigure((0,1,2), weight=1)
            
        self.frame_top.bind("<B1-Motion>", self.move_window)
        self.frame_top.bind("<ButtonPress-1>", self.oldxyset)

        if self.cancel_button=="cross":
            self.button_close = customtkinter.CTkButton(self.frame_top, corner_radius=10, width=0, height=0, hover=False, border_width=0,
                                                        text_color=self.dot_color if self.dot_color else self.title_color,
                                                      text="✕", fg_color="transparent", command=self.button_event)
            self.button_close.grid(row=0, column=5, sticky="ne", padx=5+self.border_width, pady=5+self.border_width)
        elif self.cancel_button=="circle":
            self.button_close = customtkinter.CTkButton(self.frame_top, corner_radius=10, width=10, height=10, hover=False, border_width=0,
                                                        text="", fg_color=self.dot_color if self.dot_color else "#c42b1c", command=self.button_event)     
            self.button_close.grid(row=0, column=5, sticky="ne", padx=10, pady=10)       
            
        self.title_label = customtkinter.CTkLabel(self.frame_top, width=1, text=self._title, text_color=self.title_color, font=self.font)
        self.title_label.grid(row=0, column=0, columnspan=6, sticky="nw", padx=(15,30), pady=5)
        self.title_label.bind("<B1-Motion>", self.move_window)
        self.title_label.bind("<ButtonPress-1>", self.oldxyset)
        
        self.info = customtkinter.CTkButton(self.frame_top,  width=1, height=self.height/2, corner_radius=0, text=self.message, font=self.font,
                                            fg_color=self.fg_color, hover=False, text_color=self.text_color, image=self.icon)
        self.info._text_label.configure(wraplength=self.width if self.scaling_factor > 1.0 else self.width / 2, justify="left")
        self.info.grid(row=1, column=0, columnspan=6, sticky="nwes", padx=self.border_width)
        
        if self.info._text_label.winfo_reqheight()>self.height/2:
            height_offset = int((self.info._text_label.winfo_reqheight())-(self.height/2) + self.height)
            self.geometry(f"{self.width}x{height_offset}")


        self.option_text_1 = option_1
        
        self.button_1 = customtkinter.CTkButton(self.frame_top, text=self.option_text_1, fg_color=self.button_color[0],
                                                width=self.button_width, font=self.font, text_color=self.bt_text_color,
                                                hover_color=self.bt_hv_color, height=self.button_height,
                                                command=lambda: self.button_event(self.option_text_1))
        

        self.option_text_2 = option_2 
        if option_2:     
            self.button_2 = customtkinter.CTkButton(self.frame_top, text=self.option_text_2, fg_color=self.button_color[1],
                                                    width=self.button_width, font=self.font, text_color=self.bt_text_color,
                                                    hover_color=self.bt_hv_color, height=self.button_height,
                                                    command=lambda: self.button_event(self.option_text_2))

        self.option_text_3 = option_3
        if option_3:
            self.button_3 = customtkinter.CTkButton(self.frame_top, text=self.option_text_3, fg_color=self.button_color[2],
                                                    width=self.button_width, font=self.font, text_color=self.bt_text_color,
                                                    hover_color=self.bt_hv_color, height=self.button_height,
                                                    command=lambda: self.button_event(self.option_text_3))
            
        if self.justify=="center":
            if button_width:
                columns = [4,3,2]
                span = 1
            else:
                columns = [4,2,0]
                span = 2
            if option_3:
                self.frame_top.columnconfigure((0,1,2,3,4,5), weight=1)
                self.button_1.grid(row=2, column=columns[0], columnspan=span, sticky="news", padx=(0,10), pady=10) 
                self.button_2.grid(row=2, column=columns[1], columnspan=span, sticky="news", padx=10, pady=10)
                self.button_3.grid(row=2, column=columns[2], columnspan=span, sticky="news", padx=(10,0), pady=10)
            elif option_2:
                self.frame_top.columnconfigure((0,5), weight=1)
                columns = [2,3]
                self.button_1.grid(row=2, column=columns[0], sticky="news", padx=(0,5), pady=10)
                self.button_2.grid(row=2, column=columns[1], sticky="news", padx=(5,0), pady=10)
            else:
                if button_width:
                    self.frame_top.columnconfigure((0,1,2,3,4,5), weight=1)
                else:
                    self.frame_top.columnconfigure((0,2,4), weight=2)
                self.button_1.grid(row=2, column=columns[1], columnspan=span, sticky="news", padx=(0,10), pady=10)   
        elif self.justify=="left":
            self.frame_top.columnconfigure((0,1,2,3,4,5), weight=1)
            if button_width:
                columns = [0,1,2]
                span = 1
            else:        
                columns = [0,2,4]
                span = 2
            if option_3:
                self.button_1.grid(row=2, column=columns[2], columnspan=span, sticky="news", padx=(0,10), pady=10)
                self.button_2.grid(row=2, column=columns[1], columnspan=span, sticky="news", padx=10, pady=10)
                self.button_3.grid(row=2, column=columns[0], columnspan=span, sticky="news", padx=(10,0), pady=10)
            elif option_2:
                self.button_1.grid(row=2, column=columns[1], columnspan=span, sticky="news", padx=10, pady=10)
                self.button_2.grid(row=2, column=columns[0], columnspan=span, sticky="news", padx=(10,0), pady=10)
            else:
                self.button_1.grid(row=2, column=columns[0], columnspan=span, sticky="news", padx=(10,0), pady=10)
        else:
            self.frame_top.columnconfigure((0,1,2,3,4,5), weight=1)
            if button_width:
                columns = [5,4,3]
                span = 1
            else:
                columns = [4,2,0]
                span = 2
            self.button_1.grid(row=2, column=columns[0], columnspan=span, sticky="news", padx=(0,10), pady=10)
            if option_2:  
                self.button_2.grid(row=2, column=columns[1], columnspan=span, sticky="news", padx=10, pady=10)
            if option_3:
                self.button_3.grid(row=2, column=columns[2], columnspan=span, sticky="news", padx=(10,0), pady=10)
                        
        if header:
            self.title_label.configure(text="")
            self.title_label.grid_configure(pady=0)
            self.button_close.configure(text_color=self.bg_color)
            self.frame_top.configure(corner_radius=0)

        if self.winfo_exists():
            self.grab_set()

        if self.sound:
            self.bell()
            
        if self.fade:
            self.fade_in()
            
        if option_focus:
            self.option_focus = option_focus
            self.focus_button(self.option_focus)
        else:
            if not self.option_text_2 and not self.option_text_3:
                self.button_1.focus()
                self.button_1.bind("<Return>", lambda event: self.button_event(self.option_text_1))
 
        self.bind("<Escape>", lambda e: self.button_event())
        
    def focus_button(self, option_focus):
        try:
            self.selected_button = getattr(self, "button_"+str(option_focus))
            self.selected_button.focus()
            self.selected_button.configure(border_color=self.bt_hv_color, border_width=3)
            self.selected_option = getattr(self, "option_text_"+str(option_focus))
            self.selected_button.bind("<Return>", lambda event: self.button_event(self.selected_option))
        except AttributeError:
            return
        
        self.bind("<Left>", lambda e: self.change_left())
        self.bind("<Right>", lambda e: self.change_right())
        
    def change_left(self):
        if self.option_focus==3:
            return
        
        self.selected_button.unbind("<Return>")
        self.selected_button.configure(border_width=0)
    
        if self.option_focus==1:
            if self.option_text_2:
                self.option_focus = 2
        
        elif self.option_focus==2:
            if self.option_text_3:
                self.option_focus = 3
  
        self.focus_button(self.option_focus)
        
    def change_right(self):
        if self.option_focus==1:
            return
        
        self.selected_button.unbind("<Return>")
        self.selected_button.configure(border_width=0)

        if self.option_focus==2:
            self.option_focus = 1

        elif self.option_focus==3:
            self.option_focus = 2
                
        self.focus_button(self.option_focus)
    
    def load_icon(self, icon, icon_size):
        if icon not in self.ICONS or self.ICONS[icon] is None:
            if icon in ["check", "cancel", "info", "question", "warning"]:
                image_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'icons', icon + '.png')
            else:
                image_path = icon
            if icon_size:
                size_height = icon_size[1] if icon_size[1] <= self.height - 100 else self.height - 100
                size = (icon_size[0], size_height)
            else:
                size = (self.height / 4, self.height / 4)
            self.ICONS[icon] = customtkinter.CTkImage(Image.open(image_path), size=size)
            self.ICON_BITMAP[icon] = ImageTk.PhotoImage(file=image_path)
        self.after(200, lambda: self.iconphoto(False, self.ICON_BITMAP[icon]))
        return self.ICONS[icon]
        
    def fade_in(self):
        for i in range(0,110,10):
            if not self.winfo_exists():
                break
            self.attributes("-alpha", i/100)
            self.update()
            time.sleep(1/self.fade)
            
    def fade_out(self):
        for i in range(100,0,-10):
            if not self.winfo_exists():
                break
            self.attributes("-alpha", i/100)
            self.update()
            time.sleep(1/self.fade)

    def get(self):
        if self.winfo_exists():
            self.master.wait_window(self)
        return self.event
        
    def oldxyset(self, event):
        self.oldx = event.x
        self.oldy = event.y
    
    def move_window(self, event):
        self.y = event.y_root - self.oldy
        self.x = event.x_root - self.oldx
        self.geometry(f'+{self.x}+{self.y}')
        
    def button_event(self, event=None):
        try:
            self.button_1.configure(state="disabled")
            self.button_2.configure(state="disabled")
            self.button_3.configure(state="disabled")
        except AttributeError:
            pass

        if self.fade:
            self.fade_out()
        self.grab_release()
        self.destroy()
        self.event = event  
        
if __name__ == "__main__":
    app = CTkMessagebox()
    app.mainloop()
src/CTkMessagebox.py
theexperiential/Owlette
import shared_utils
import logging
import json
import keyring
from datetime import datetime
import time
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
import base64
import argparse

# Take an input of the app name that was restarted
parser = argparse.ArgumentParser(description='Send email notifications.')
parser.add_argument('--process_name', type=str, help='Name of the process to notify about')
parser.add_argument('--reason', type=str, help='Reason for the email notification')
args = parser.parse_args()
process_name = args.process_name
reason = args.reason

def send_email(app_name, reason):
    # Load config
    with open(shared_utils.get_path('../config/config.json'), 'r') as f:
        config = json.load(f)

    # Get refresh token
    refresh_token = keyring.get_password("Owlette", "GmailRefreshToken")

    with open(shared_utils.get_path('../config/client_secrets.json'), 'r') as f:
        client_info = json.load(f)
        client_id = client_info['installed']['client_id']
        client_secret = client_info['installed']['client_secret']
        token_uri = client_info['installed']['token_uri']

    # Initialize the Gmail API client
    credentials = Credentials.from_authorized_user_info({
        'refresh_token': refresh_token,
        'client_id': client_id,
        'client_secret': client_secret,
        'token_uri': token_uri
    })
    service = build('gmail', 'v1', credentials=credentials)

    # Get system info
    system_info = shared_utils.get_system_info()

    # Email subject and body
    hostname = shared_utils.get_hostname()
    # Modify the email subject and body based on the reason
    if reason == "restarted":
        subject = f"Owlette on {hostname} - {app_name} restarted"
        body_intro = f"Owlette detected that the app '{app_name}' was restarted on {hostname} at {datetime.now()}."
    elif reason == "frozen":
        subject = f"Owlette on {hostname} - {app_name} is frozen"
        body_intro = f"Owlette detected that the app '{app_name}' is frozen on {hostname} at {datetime.now()}."
    else:
        subject = f"Owlette on {hostname} - {app_name} status update"
        body_intro = f"Owlette detected a status update for the app '{app_name}' on {hostname} at {datetime.now()}. \nDetails: {reason}"

    body = f"""
        {body_intro}
        System Info:
        - CPU Model: {system_info['cpu_model']}
        - CPU Usage: {system_info['cpu_usage']}%
        - Memory Used/Total: {system_info['memory_used']} GB/{system_info['memory_total']} GB
        - Disk Used/Total: {system_info['disk_used']} GB/{system_info['disk_total']} GB
        - GPU Model: {system_info['gpu_model']}
        - GPU VRAM Used/Total: {system_info['gpu_info']} MB/{system_info['gpu_total']} MB
        """

    # Create the email message
    msg = MIMEMultipart()
    msg['Subject'] = subject
    msg.attach(MIMEText(body, 'plain'))

    # Loop through each email in the 'to' list
    for to_email in config['gmail']['to']:
        msg['To'] = to_email
        raw_message = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
        message = {'raw': raw_message}
        message = service.users().messages().send(userId='me', body=message).execute()

        logging.info(f"Sent email to {[msg['To']]} about {app_name}")

    # confirmation print for initial email config
    if app_name == 'Owlette Mail Service':
        print('Sent successfully')

try:
    send_email(process_name, reason)
except Exception as e:
    logging.error(e)
src/owlette_gmail.py
theexperiential/Owlette
import shared_utils
import owlette_slack
import tkinter as tk
from tkinter import filedialog
import customtkinter as ctk
from CTkListbox import *
from CTkMessagebox import CTkMessagebox
import os
import signal
import json
from email_validator import validate_email, EmailNotValidError
from google_auth_oauthlib.flow import InstalledAppFlow
import keyring
import logging
import uuid
import threading
import subprocess
import time
import win32serviceutil

class OwletteConfigApp:

    def __init__(self, master):
        # Check service is running
        service_name = shared_utils.SERVICE_NAME
        is_running = self.check_service_is_running(service_name)
        # If not, start it
        if not is_running:
            self.start_service()

        self.master = master
        self.entry = ctk.CTkEntry(master)
        self.entry.grid(row=0, column=0)
        
        # Initialize UI
        self.master.title(shared_utils.WINDOW_TITLES.get("owlette_gui"))
        shared_utils.center_window(master, 1024, 550)
        self.setup_ui()

        # Load existing config after defining email entry widgets
        self.config = shared_utils.load_config(self.emails_to_entry)

        # Process list
        self.prev_process_list = None
        self.selected_process = None
        self.selected_index = None
        self.update_process_list()
        self.master.after(1000, self.update_process_list_periodically)

        # Set default values if empty
        if not self.time_delay_entry.get():
            self.time_delay_entry.insert(0, 0)
        if not self.time_to_init_entry.get():
            self.time_to_init_entry.insert(0, 10)
        if not self.relaunch_attempts_entry.get():
            self.relaunch_attempts_entry.insert(0, 3)

        # Initialize Alert (Gmail/Slack) states
        if self.config['gmail'].get('enabled', False):
            self.gmail_toggle.select()
        else:
            self.gmail_toggle.deselect()

        if self.config['slack'].get('enabled', False):
            self.slack_toggle.select()
        else:
            self.slack_toggle.deselect()

        # Update the process list based on JSON config contents
        self.update_process_list()

        #CTkMessagebox(title="Info", message="This is a CTkMessagebox!", justify="center")

    def setup_ui(self):
        # PROCESS DETAILS
        # Create frame for process details
        self.process_details_frame = ctk.CTkFrame(master=self.master, fg_color=shared_utils.FRAME_COLOR)
        self.process_details_frame.grid(row=0, column=0, sticky='news', rowspan=8, columnspan=4, padx=10, pady=(10,0))

        # Create a label for the process details
        self.process_details_label = ctk.CTkLabel(self.master, text="PROCESS DETAILS", fg_color=shared_utils.FRAME_COLOR)
        self.process_details_label.grid(row=0, column=0, sticky='w', padx=(20, 10), pady=(20, 0))

        # Create a toggle switch for process
        self.autolaunch_label = ctk.CTkLabel(self.master, text="Autolaunch/Manage:", fg_color=shared_utils.FRAME_COLOR)
        self.autolaunch_label.grid(row=1, column=0, sticky='e', padx=5, pady=5)
        self.autolaunch_toggle = ctk.CTkSwitch(master=self.master, text="", command=self.toggle_launch_process, onvalue="on", offvalue="off")
        self.autolaunch_toggle.grid(row=1, column=1, sticky='w', padx=10, pady=(0, 0))
        self.autolaunch_toggle.configure(bg_color=shared_utils.FRAME_COLOR, fg_color='red', progress_color=shared_utils.BUTTON_IMPORTANT_COLOR)
        self.autolaunch_toggle.select()

        # Create Name of process field
        self.name_label = ctk.CTkLabel(self.master, text="Name:", fg_color=shared_utils.FRAME_COLOR)
        self.name_label.grid(row=2, column=0, sticky='e', padx=5, pady=5)
        self.name_entry = ctk.CTkEntry(self.master, placeholder_text="Name of your process")
        self.name_entry.grid(row=2, column=1, columnspan=2, sticky='ew', padx=5, pady=5)

        # Create Exe path field
        self.exe_path_label = ctk.CTkLabel(self.master, text="Executable Path:", fg_color=shared_utils.FRAME_COLOR)
        self.exe_path_label.grid(row=3, column=0, sticky='e', padx=5, pady=5)
        self.exe_path_entry = ctk.CTkEntry(self.master, placeholder_text="The full path to your executable (application)")
        self.exe_path_entry.grid(row=3, column=1, columnspan=2, sticky='ew', padx=5, pady=5)

        # Create Browse button for Exe
        self.exe_browse_button = ctk.CTkButton(self.master, text="Browse", command=self.browse_exe, width=60, fg_color=shared_utils.BUTTON_COLOR, hover_color=shared_utils.BUTTON_HOVER_COLOR)
        self.exe_browse_button.grid(row=3, column=3, sticky='w', padx=5, pady=5)
        self.exe_browse_button.configure(width=80)

        # Create File path / cmd line args
        self.file_path_label = ctk.CTkLabel(self.master, text="File Path / Cmd Args:", fg_color=shared_utils.FRAME_COLOR)
        self.file_path_label.grid(row=4, column=0, sticky='e', padx=(10, 5), pady=5)
        self.file_path_entry = ctk.CTkEntry(self.master, placeholder_text="The full path to your document or command line arguments")
        self.file_path_entry.grid(row=4, column=1, columnspan=2, sticky='ew', padx=5, pady=5)

        # Create Browse button for File
        self.file_browse_button = ctk.CTkButton(self.master, text="Browse", command=self.browse_file, width=60, fg_color=shared_utils.BUTTON_COLOR, hover_color=shared_utils.BUTTON_HOVER_COLOR)
        self.file_browse_button.grid(row=4, column=3, sticky='w', padx=5, pady=5)
        self.file_browse_button.configure(width=80)

        # Create Time delay label and field
        self.time_delay_label = ctk.CTkLabel(self.master, text="Launch Time Delay (s):", fg_color=shared_utils.FRAME_COLOR)
        self.time_delay_label.grid(row=5, column=0, sticky='e', padx=5, pady=5)
        self.time_delay_entry = ctk.CTkEntry(self.master, placeholder_text="0", width=60)
        self.time_delay_entry.grid(row=5, column=1, sticky='w', padx=5, pady=5)
        self.time_delay_entry.configure(width=80)

        # Create a label and entry for "Time to Initialize"
        self.time_to_init_label = ctk.CTkLabel(self.master, text="Time to Initialize (s):", fg_color=shared_utils.FRAME_COLOR)
        self.time_to_init_label.grid(row=6, column=0, sticky='e', padx=5, pady=5)
        self.time_to_init_entry = ctk.CTkEntry(self.master, placeholder_text="10", width=60)
        self.time_to_init_entry.grid(row=6, column=1, sticky='w', padx=5, pady=5)
        self.time_to_init_entry.configure(width=80)

        # Create a label and entry for "Restart Attempts"
        self.relaunch_attempts_label = ctk.CTkLabel(self.master, text="Relaunch attempts til Restart:", fg_color=shared_utils.FRAME_COLOR)
        self.relaunch_attempts_label.grid(row=7, column=0, sticky='e', padx=5, pady=5)
        self.relaunch_attempts_entry = ctk.CTkEntry(self.master, placeholder_text="3", width=60)
        self.relaunch_attempts_entry.grid(row=7, column=1, sticky='w', padx=5, pady=5)
        self.relaunch_attempts_entry.configure(width=80)

        # Create Priority dropdown
        self.priority_label = ctk.CTkLabel(self.master, text="Priority:", fg_color=shared_utils.FRAME_COLOR)
        self.priority_label.grid(row=5, column=2, sticky='e', padx=5, pady=5)
        self.priority_options = ["Low", "Normal", "High", "Realtime"]
        self.priority_menu = ctk.CTkOptionMenu(self.master, values=self.priority_options, command=self.update_selected_process)
        self.priority_menu.configure(fg_color=shared_utils.BUTTON_COLOR, button_color=shared_utils.BUTTON_IMPORTANT_COLOR, button_hover_color=shared_utils.BUTTON_HOVER_COLOR, width=140)
        self.priority_menu.grid(row=5, column=3, sticky='w', padx=5, pady=5)
        self.priority_menu.set('Normal')

        # Create Visibility dropdown
        self.visibility_label = ctk.CTkLabel(self.master, text="Window Visibility:", fg_color=shared_utils.FRAME_COLOR)
        self.visibility_label.grid(row=6, column=2, sticky='e', padx=5, pady=5)
        self.visibility_options = ["Show", "Hide"]
        self.visibility_menu = ctk.CTkOptionMenu(self.master, values=self.visibility_options, command=self.update_selected_process)
        self.visibility_menu.configure(width=80, fg_color=shared_utils.BUTTON_COLOR, button_color=shared_utils.BUTTON_IMPORTANT_COLOR, button_hover_color=shared_utils.BUTTON_HOVER_COLOR)
        self.visibility_menu.grid(row=6, column=3, sticky='w', padx=5, pady=5)
        self.visibility_menu.set('Show')

        # Create Add Process button
        self.add_button = ctk.CTkButton(self.master, text="Add", command=self.add_process, width=60, fg_color=shared_utils.BUTTON_IMPORTANT_COLOR, hover_color=shared_utils.BUTTON_HOVER_COLOR)
        self.add_button.grid(row=7, column=3, sticky='w', padx=(5, 0), pady=(5, 10))
        self.add_button.configure(width=140)

        # PROCESS LIST
        # Create a frame for the process list
        self.process_list_frame = ctk.CTkFrame(master=self.master, fg_color=shared_utils.FRAME_COLOR)
        self.process_list_frame.grid(row=0, column=4, sticky='news', rowspan=8, columnspan=4, padx=(0, 10), pady=(10,0))

        # Create a label for the process list
        self.process_list_label = ctk.CTkLabel(self.master, text="PROCESS LAUNCH LIST", fg_color=shared_utils.FRAME_COLOR)
        self.process_list_label.grid(row=0, column=4, sticky='w', padx=10, pady=(20, 0))
        self.process_list_label.configure(width=40)

        # Create a Listbox to display the list of processes
        self.process_list = CTkListbox(self.master, command=self.on_select)
        self.process_list.grid(row=1, column=4, columnspan=3, rowspan=6, sticky='nsew', padx=(10,20), pady=10)
        self.process_list.configure(highlight_color=shared_utils.BUTTON_IMPORTANT_COLOR, hover_color=shared_utils.BUTTON_HOVER_COLOR, width=80)

        # Create Kill button
        self.kill_button = ctk.CTkButton(self.master, text="Kill", command=self.kill_process, width=60, fg_color=shared_utils.BUTTON_COLOR, hover_color=shared_utils.BUTTON_HOVER_COLOR)
        self.kill_button.grid(row=7, column=4, sticky='e', padx=(10, 20), pady=(5, 10))

        # Create Delete button
        self.remove_button = ctk.CTkButton(self.master, text="Delete", command=self.remove_process, width=60, fg_color=shared_utils.BUTTON_COLOR, hover_color=shared_utils.BUTTON_HOVER_COLOR)
        self.remove_button.grid(row=7, column=4, sticky='w', padx=(10, 20), pady=(5, 10))

        # Up and down buttons
        self.up_button = ctk.CTkButton(self.master, text="Up", command=self.move_up, width=60, fg_color=shared_utils.BUTTON_COLOR, hover_color=shared_utils.BUTTON_HOVER_COLOR)
        self.up_button.grid(row=7, column=5, sticky='w', padx=(10, 10), pady=(5, 10))

        self.down_button = ctk.CTkButton(self.master, text="Down", command=self.move_down, width=60, fg_color=shared_utils.BUTTON_COLOR, hover_color=shared_utils.BUTTON_HOVER_COLOR)
        self.down_button.grid(row=7, column=6, sticky='e', padx=(10, 20), pady=(5, 10))

        ## NOTIFICATIONS
        # Create a frame for the notifications section
        self.notifications_frame = ctk.CTkFrame(master=self.master, fg_color=shared_utils.FRAME_COLOR)
        self.notifications_frame.grid(row=8, column=0, sticky='news', rowspan=3, columnspan=4, padx=(10,10), pady=(10,10))
        
        # Create a label for the notifications section
        self.notifications_label = ctk.CTkLabel(self.master, text="NOTIFICATIONS", fg_color=shared_utils.FRAME_COLOR)
        self.notifications_label.grid(row=8, column=0, sticky='w', padx=(20, 10), pady=(20, 0))

        # Create Labels and Entry widgets for email configuration
        self.emails_to_label = ctk.CTkLabel(self.master, text="Emails To:", fg_color=shared_utils.FRAME_COLOR)
        self.emails_to_label.grid(row=9, column=0, sticky='e', padx=5, pady=10)
        self.emails_to_entry = ctk.CTkEntry(self.master, placeholder_text="Email Addresses (comma separated)")
        self.emails_to_entry.grid(row=9, column=1, columnspan=2, sticky='ew', padx=5, pady=10)

        # Create Gmail toggle
        self.gmail_toggle = ctk.CTkSwitch(master=self.master, text="Gmail", command=self.toggle_gmail, onvalue="on", offvalue="off")
        self.gmail_toggle.configure(bg_color=shared_utils.FRAME_COLOR, progress_color=shared_utils.BUTTON_IMPORTANT_COLOR)
        self.gmail_toggle.grid(row=10, column=1, sticky='w', padx=(5), pady=(10, 10))

        # Create Slack toggle
        self.slack_toggle = ctk.CTkSwitch(master=self.master, text="Slack", command=self.toggle_slack, onvalue="on", offvalue="off")
        self.slack_toggle.configure(bg_color=shared_utils.FRAME_COLOR, progress_color=shared_utils.BUTTON_IMPORTANT_COLOR)
        self.slack_toggle.grid(row=10, column=2, sticky='e', padx=(5), pady=(10, 10))


        # SAVE SECTION / MISC
        # Create Save Changes button
        self.save_button = ctk.CTkButton(self.master, text="Save Changes", command=self.update_selected_process, fg_color=shared_utils.BUTTON_COLOR, hover_color=shared_utils.BUTTON_HOVER_COLOR)
        self.save_button.grid(row=10, column=5, columnspan=2, sticky='ew', padx=(10, 20), pady=(10, 10))

        # Create a label for the app version
        self.version_label = ctk.CTkLabel(self.master, text=f"v{shared_utils.APP_VERSION}", fg_color='transparent')
        self.version_label.grid(row=10, column=4, sticky='e', padx=10, pady=(0, 0))
        self.version_label.configure(width=40)

        # BINDINGS
        # Bind the Entry widgets to update the selected process when the Return key is pressed
        self.name_entry.bind('<Return>', self.update_selected_process)
        self.exe_path_entry.bind('<Return>', self.update_selected_process)
        self.file_path_entry.bind('<Return>', self.update_selected_process)
        self.emails_to_entry.bind('<Return>', self.update_email_config)
        self.time_delay_entry.bind('<Return>', self.update_selected_process)
        self.time_to_init_entry.bind('<Return>', self.update_selected_process)
        self.relaunch_attempts_entry.bind('<Return>', self.update_selected_process)

        # Bind everything to mouse clicks
        self.name_entry.bind('<Button-1>', self.on_click)
        self.exe_path_entry.bind('<Button-1>', self.on_click)
        self.file_path_entry.bind('<Button-1>', self.on_click)
        self.emails_to_entry.bind('<Button-1>', self.on_click)
        self.time_delay_entry.bind('<Button-1>', self.on_click)
        self.time_to_init_entry.bind('<Button-1>', self.on_click)
        self.relaunch_attempts_entry.bind('<Button-1>', self.on_click)
        self.process_list.bind('<Button-1>', self.on_click)

        # Bind a mouse click event to the root window to defocus the entry
        self.master.bind("<Button-1>", self.defocus_entry)

        # Make columns stretchable
        self.master.grid_columnconfigure(0, weight=2) # labels
        self.master.grid_columnconfigure(1, weight=3)
        self.master.grid_columnconfigure(2, weight=3)
        self.master.grid_columnconfigure(3, weight=3)
        self.master.grid_columnconfigure(4, weight=1)
        self.master.grid_columnconfigure(5, weight=1)
        self.master.grid_columnconfigure(6, weight=1)

        self.master.grid_rowconfigure(8, weight=2)
        self.master.grid_rowconfigure(9, weight=2)
        self.master.grid_rowconfigure(10, weight=2)

    # PROCESS HANDLING

    def toggle_launch_process(self):
        if self.selected_process:
            index = shared_utils.get_process_index(self.selected_process)
            current_state = self.config['processes'][index].get('autolaunch', False)
            self.config['processes'][index]['autolaunch'] = not current_state
            shared_utils.save_config(self.config)

            # Status message if process has been launched
            try:
                pid = shared_utils.fetch_pid_by_id(self.config['processes'][index]['id'])
                shared_utils.update_process_status_in_json(pid, 'UNKNOWN' if current_state else 'QUEUED')
            except Exception as e:
                logging.info(e)

    def update_selected_process(self,event=None):
        # Field Validation
        name = self.name_entry.get()
        exe_path = self.exe_path_entry.get()
        file_path = self.file_path_entry.get()
        priority = self.priority_menu.get()
        visibility = self.visibility_menu.get()
        time_delay = self.time_delay_entry.get()
        time_to_init = self.time_to_init_entry.get()
        relaunch_attempts = self.relaunch_attempts_entry.get()

        # Validate Time Delay
        try:
            if float(time_delay):  # Try converting the time delay to a float
                if float(time_delay) < 0:
                    raise ValueError("Start Time Delay must be greater than or equal to 0.")

        except ValueError:
            CTkMessagebox(master=self.master, title="Validation Error", message="Start Time Delay must be a number (integer or float).", icon="cancel")
            self.time_delay_entry.delete(0, tk.END)
            self.time_delay_entry.insert(0, 0)
            return

        # Validate Time To Init
        try:
            if float(time_to_init):  # Try converting the time to init to a float
                if float(time_to_init) < 10 or float(time_to_init) == 0:
                    raise ValueError("Time to initialize must be greater than or equal to 10 seconds.")
        except ValueError:
            CTkMessagebox(master=self.master, title="Validation Error", message="Time to Initialize must be at least 10 seconds", icon="cancel")
            self.time_to_init_entry.delete(0, tk.END)
            self.time_to_init_entry.insert(0, 10)
            return

        # Validate Relaunch Attempts
        try:
            if int(relaunch_attempts):  # Try converting the relaunch attempts to an integer
                if int(relaunch_attempts) < 0:
                    raise ValueError("Relaunch attempts must be >=0")
        except ValueError:
            CTkMessagebox(master=self.master, title="Validation Error", message="Relaunch attempts must be an integer. 3 is recommended. After 3 attempts, a system restart will be attempted. Set to 0 for unlimited attempts to relaunch (no system restart).", icon="cancel")
            self.relaunch_attempts_entry.delete(0, tk.END)
            self.relaunch_attempts_entry.insert(0, 3)
            return

        # Check if relaunch attempts is empty and set to default if so
        if not relaunch_attempts:
            relaunch_attempts = 3  # Default value

        # Check if time to init is empty and set to default if so
        if not time_to_init:
            relaunch_attempts = 60  # Default value

        # Write config
        if self.selected_process:
            # Require Name/Exe Paths
            if not name or not exe_path:
                CTkMessagebox(master=self.master, title="Validation Error", message="Name and Exe Path are required fields.", icon="cancel")
                return

            index = shared_utils.get_process_index(self.selected_process)

            self.config['processes'][index]['name'] = name
            self.config['processes'][index]['exe_path'] = exe_path
            self.config['processes'][index]['file_path'] = file_path
            self.config['processes'][index]['priority'] = priority
            self.config['processes'][index]['visibility'] = visibility
            self.config['processes'][index]['time_delay'] = time_delay
            self.config['processes'][index]['time_to_init'] = time_to_init
            self.config['processes'][index]['relaunch_attempts'] = relaunch_attempts

            shared_utils.save_config(self.config)
            self.update_process_list()

            # Re-select the process
            self.process_list.activate(index)

        self.update_email_config()  # Always update email config

        self.master.focus_set() # Defocus from the entry widget back to root

    def add_process(self):
        # Generate a unique ID for the new process
        unique_id = str(uuid.uuid4())

        name = self.name_entry.get()
        exe_path = self.exe_path_entry.get()
        file_path = self.file_path_entry.get()
        priority = self.priority_menu.get()
        visibility = self.visibility_menu.get()
        time_delay = self.time_delay_entry.get() if self.time_delay_entry.get() else 0 # Default to 0 if empty
        time_to_init = self.time_to_init_entry.get() if self.time_to_init_entry.get() else 60 # Default to 60 if empty
        relaunch_attempts = self.relaunch_attempts_entry.get() if self.relaunch_attempts_entry.get() else 3 # Default to 3 if empty
        autolaunch = True if self.autolaunch_toggle.get() == 'on' else False
        
        if not name or not exe_path:
            CTkMessagebox(master=self.master, title="Validation Error", message="Name and Exe Path are required fields.", icon="cancel")
            return
        
        if not os.path.exists(exe_path):
            CTkMessagebox(master=self.master, title="Validation Error", message="The specified Exe Path does not exist.", icon="cancel")
            return
        
        if file_path and not os.path.exists(file_path):
            CTkMessagebox(master=self.master, title="Validation Error", message="The specified File Path does not exist.", icon="cancel")
            return

        new_process = {
            'id': unique_id,
            'name': name,
            'exe_path': exe_path,
            'file_path': file_path,
            'priority': priority,
            'visibility': visibility,
            'time_delay': time_delay,
            'time_to_init': time_to_init,
            'relaunch_attempts': relaunch_attempts,
            'autolaunch': autolaunch
        }

        self.config['processes'].append(new_process)
        shared_utils.save_config(self.config)
        self.update_process_list()

    # BROWSING FOR FILES

    def browse_exe(self):
        exe_path = filedialog.askopenfilename(initialdir="C:/", title="Select Exe File", filetypes=[("Executable files", "*.exe")])
        self.exe_path_entry.delete(0, tk.END)
        self.exe_path_entry.insert(0, exe_path)
        self.update_selected_process()

    def browse_file(self):
        file_path = filedialog.askopenfilename(initialdir="C:/", title="Select File")
        self.file_path_entry.delete(0, tk.END)
        self.file_path_entry.insert(0, file_path)
        self.update_selected_process()

    # PROCESS LIST

    def get_os_pid_by_process_id(self, process_list_id, result_file_path):
        app_states = shared_utils.read_json_from_file(result_file_path)
        
        # Filter the dictionary by the process_list_id
        filtered_dict = {pid: info for pid, info in app_states.items() if info.get('id') == process_list_id}
        
        # Sort the filtered dictionary by the timestamp
        sorted_dict = {k: v for k, v in sorted(filtered_dict.items(), key=lambda item: item[1]['timestamp'], reverse=True)}
        
        # Get the last (latest) pid
        last_pid = next(iter(sorted_dict.keys()), None)
        
        return int(last_pid) if last_pid else None

    def kill_process(self):
        if self.selected_process:
            os_pid = self.get_os_pid_by_process_id(self.selected_process, shared_utils.RESULT_FILE_PATH)
            killed = False
            if os_pid:
                try:
                    os.kill(os_pid, signal.SIGTERM)  # or signal.SIGKILL
                    killed = True
                except Exception as e:
                    CTkMessagebox(master=self.master, title="Error", message=f"Failed to kill the process: {e}", icon="cancel")
            else:
                CTkMessagebox(master=self.master, title="Error", message="No OS process ID found for the selected process.", icon="cancel")

            if killed:
                shared_utils.update_process_status_in_json(os_pid, 'KILLED')
        else:
            CTkMessagebox(master=self.master, title="Error", message=f"You must select a process to kill it.", icon="cancel")

    def map_status_to_config(self, status_data, config_data):
        id_to_status = {}
        for pid, info in status_data.items():
            id_ = info.get('id', None)
            status = info.get('status', None)
            if id_ and status:
                id_to_status[id_] = status

        for process in config_data['processes']:
            id_ = process.get('id', None)
            if id_:
                process['status'] = id_to_status.get(id_, "UNKNOWN")

        return config_data

    def update_process_list(self):
        # Get current keyboard focus (selected entry widget)
        current_focus = str(self.master.focus_get())
        #logging.error(f'current focus = {current_focus}')
        
        # Get currently selected item from process list
        self.selected_index = self.process_list.curselection()

        status_data = shared_utils.read_json_from_file(shared_utils.RESULT_FILE_PATH)
        config = shared_utils.read_config()
        updated_config = self.map_status_to_config(status_data, config)
        
        new_list = [f"{process['status']} - {process['name']}" for process in updated_config['processes']]

        if new_list != self.prev_process_list:
            if self.process_list.size() > 0:
                self.process_list.delete(0, 'end')  # Clear the existing listbox items
            for item in new_list:
                self.process_list.insert('end', item)
            self.prev_process_list = new_list  # Update the previous list

        # Try to reselect process list item automatically (if not editing an entry)
        if self.selected_index is not None and current_focus == '.' or current_focus is None:
            try:
                self.process_list.activate(self.selected_index)
            except Exception as e:
                logging.info(e)

    def update_process_list_periodically(self):
        self.update_process_list()
        self.master.after(1000, self.update_process_list_periodically)  # Schedule next run

    def remove_process(self):
        if self.selected_process:
            process = shared_utils.fetch_process_by_id(self.selected_process, self.config)
            if process:
                process_name = shared_utils.fetch_process_name_by_id(self.selected_process, self.config)
                response = CTkMessagebox(master=self.master, title="Remove Process?", message=f"Are you sure you want to remove {process_name}?", icon="question", option_1="Yes", option_2="No")
                if response.get() == 'Yes':
                    index = shared_utils.get_process_index(self.selected_process)
                    if index is not None:
                        del self.config['processes'][index]
                        shared_utils.save_config(self.config)
                        self.update_process_list()            
            else:
                CTkMessagebox(master=self.master, title="Error", message=f"No process found with the name '{self.selected_process}'", icon="cancel")
        else:
            CTkMessagebox(master=self.master, title="Error", message=f"You must select a process to remove it.", icon="cancel")

    def move_up(self):
        if self.selected_process:
            index = shared_utils.get_process_index(self.selected_process)
            if index > 0:
                self.config['processes'][index], self.config['processes'][index-1] = self.config['processes'][index-1], self.config['processes'][index]
                shared_utils.save_config(self.config)
                self.update_process_list()
                self.process_list.activate(index-1)
        else:
            CTkMessagebox(master=self.master, title="Error", message=f"You must select a process move it up in the list.", icon="cancel")

    def move_down(self):
        if self.selected_process:
            index = shared_utils.get_process_index(self.selected_process)
            if index < len(self.config['processes']) - 1:
                self.config['processes'][index], self.config['processes'][index+1] = self.config['processes'][index+1], self.config['processes'][index]
                shared_utils.save_config(self.config)
                self.update_process_list()
                self.process_list.activate(index+1)
        else:
            CTkMessagebox(master=self.master, title="Error", message=f"You must select a process to move it down in the list.", icon="cancel")

    def on_select(self, process_name):
        if " - " in process_name:
            process_name = process_name.split(" - ")[1]
        process_id = shared_utils.fetch_process_id_by_name(process_name, self.config)
        self.selected_process = process_id
        process = shared_utils.fetch_process_by_id(process_id, self.config)
        self.name_entry.delete(0, tk.END)
        self.name_entry.insert(0, process.get('name', ''))
        self.exe_path_entry.delete(0, tk.END)
        self.exe_path_entry.insert(0, process.get('exe_path', ''))
        self.visibility_menu.set(process.get('visibility', 'Show'))
        self.priority_menu.set(process.get('priority', 'Normal'))
        self.file_path_entry.delete(0, tk.END)
        self.file_path_entry.insert(0, process.get('file_path', ''))
        self.time_delay_entry.delete(0, tk.END)
        self.time_delay_entry.insert(0, process.get('time_delay', ''))
        self.time_to_init_entry.delete(0, tk.END)
        self.time_to_init_entry.insert(0, process.get('time_to_init', ''))
        self.relaunch_attempts_entry.delete(0, tk.END)
        self.relaunch_attempts_entry.insert(0, process.get('relaunch_attempts', ''))
        autolaunch = process.get('autolaunch', True)
        if autolaunch:
            self.autolaunch_toggle.select()
        else:
            self.autolaunch_toggle.deselect()

    # GMAIL

    def toggle_gmail(self):
        if self.emails_to_entry.get():
            if self.gmail_toggle.get() == 'on':
                self.start_google_auth_thread()
                self.config['gmail']['enabled'] = True
            else:
                self.config['gmail']['enabled'] = False
            shared_utils.save_config(self.config)
        else:
            self.gmail_toggle.deselect()
            CTkMessagebox(master=self.master, title="No Emails", message="Please enter at least one email address to use the Gmail API.")

    def validate_email_address(self, email):
        try:
            # validate and get info
            v = validate_email(email)
            return True
        except EmailNotValidError as e:
            # email is not valid, exception message is human-readable
            CTkMessagebox(master=self.master, title='Email address invalid', message=f"{e}", icon="cancel")
            return False

    def update_email_config(self, event=None):
        emails_to = self.emails_to_entry.get().split(',')
        for email in emails_to:
            if email.strip():  # Skip validation for empty items in the list
                if not self.validate_email_address(email.strip()):
                    return

        self.config['gmail']['to'] = [email.strip() for email in emails_to]
        shared_utils.save_config(self.config)

    def send_confirmation_email(self):
        try:
            script = shared_utils.get_path("owlette_gmail.py")
            arg1 = '--process_name'
            arg2 = 'Owlette Mail Service'
            arg3 = '--reason'
            arg4 = 'Owlette has been successfully connected to your Gmail.'            
            email = subprocess.run(["python", script, arg1, arg2, arg3, arg4], capture_output=True, text=True)

            if email:
                # Let the user know that we really did send an email
                CTkMessagebox(master=self.master, title="Success", message="Confirmation email sent. Please check your inbox.")

        except Exception as e:
            logging.error(f'Error sending confirmation email: {e}')

    def get_google_auth_token(self):
        refresh_token = keyring.get_password("Owlette", "GmailRefreshToken")
        if not refresh_token:
            try:
                # Initialize the flow
                flow = InstalledAppFlow.from_client_secrets_file(
                    shared_utils.get_path('../config/client_secrets.json'),
                    scopes=['https://www.googleapis.com/auth/gmail.send']
                )
                # Run the flow
                credentials = flow.run_local_server(port=0)
                # Store the token in Windows Credentials
                keyring.set_password("Owlette", "GmailRefreshToken", credentials.refresh_token)
                logging.info('Added Gmail Refresh Token to Windows Credentials')

                self.send_confirmation_email()

            except Exception as e:
                logging.error(f'Gmail Authentication Error: {e}')

        # If token stored, send test email as confirmation
        else:
            self.send_confirmation_email()
            
    def start_google_auth_thread(self):
        auth_thread = threading.Thread(target=self.get_google_auth_token)
        auth_thread.daemon = True  # This ensures the thread will exit when the main program exits
        auth_thread.start()

    # SLACK

    def toggle_slack(self):
        if self.slack_toggle.get() == 'on':
            self.setup_slack()
            self.config['slack']['enabled'] = True
        else:
            self.config['slack']['enabled'] = False
        shared_utils.save_config(self.config)
          
    def setup_slack(self):
        # Check if the token exists
        self.bot_user_oauth_token = owlette_slack.get_cred('Owlette', 'SlackBotUserOAuthToken')

        # If it doesn't exist, ask the user to provide it
        if self.bot_user_oauth_token is None:
            # Open a new window (prompt_slack_config.py) to get client details
            result = subprocess.run(["python", shared_utils.get_path("prompt_slack_config.py")], capture_output=True, text=True)
            self.bot_user_oauth_token = result.stdout.strip()

        # Create Slack channel
        channel_name = 'hootbot'
        channel_id = owlette_slack.get_cred('Owlette', 'ChannelID')
        if not channel_id:
            channel_id = owlette_slack.create_channel(self.bot_user_oauth_token, channel_name)

        if channel_id:
            # Post a confirmation Slack message
            msg = f":owl: Hoo hoo! from :computer: {shared_utils.get_hostname()}\n> :wave: I'm connected to Slack :thumbsup: What a _hoot_!"
            if owlette_slack.send_message(msg):
                # message success, Slack is configured, set to true in JSON
                shared_utils.write_config(['slack', 'enabled'], True)

                # Let the user know that we really did send a message to their slack
                CTkMessagebox(master=self.master, title="Success", message="Message delivered. Please check your Slack in the #owlette channel.")

    # UI

    def on_click(self, event):
        shared_utils.save_config(self.config)
        if 'ButtonPress' in str(event):
            self.update_process_list()

    def defocus_entry(self, event):
        widget = self.master.winfo_containing(event.x_root, event.y_root)
        if 'ctkframe' in str(widget):
            self.master.focus_set()  # Transfers focus to the root window
            shared_utils.save_config(self.config) # Saves config

    # SYSTEM/MISC

    def check_service_is_running(self, service_name):
        try:
            status = win32serviceutil.QueryServiceStatus(service_name)[1]
            if status == 4:  # 4 means the service is running
                return True
            else:
                return False
        except Exception as e:
            print(f"An error occurred: {e}")
            return None

    def start_service(self):
        try:
            subprocess.Popen(
                ["pythonw", shared_utils.get_path("start_service.py")],
                creationflags=subprocess.CREATE_NO_WINDOW
            )
            print("Service started successfully.")
        except Exception as e:
            print(f"Failed to start service: {e}")

if __name__ == "__main__":
    # Initialize logging
    shared_utils.initialize_logging("gui")
    root = ctk.CTk()
    app = OwletteConfigApp(root)
    root.mainloop()
src/owlette_gui.py
theexperiential/Owlette
import ctypes
import json
import os
import sys
import time
import win32gui
import win32process
import shared_utils

def is_app_responsive(pid):
    hung_windows = []
    def enum_windows_callback(hwnd, extra):
        _, curr_pid = win32process.GetWindowThreadProcessId(hwnd)
        if curr_pid == pid:
            if ctypes.windll.user32.IsHungAppWindow(hwnd):
                hung_windows.append(hwnd)
    win32gui.EnumWindows(enum_windows_callback, None)
    
    # Return False if any hung windows are found, otherwise return True
    if hung_windows:
        return False
    return True

# Get the PID from command-line arguments
pid = int(sys.argv[1])
current_time = int(time.time())  # Get the current timestamp
result = True

# Read existing results from the output file
results = shared_utils.read_json_from_file(shared_utils.RESULT_FILE_PATH)

# Check if the process has a timestamp and if it's older than 60 seconds
process_info = results.get(str(pid), {})
timestamp = process_info.get('timestamp', 0)
current_time = int(time.time())
time_since_launch = current_time - timestamp

if time_since_launch < 60:
    print("Ignoring the process as it was launched less than 60 seconds ago.")
    result = True
else:
    result = is_app_responsive(pid)

    # Update the results dictionary
    if str(pid) not in results:
        results[str(pid)] = {}
    results[str(pid)]['responsive'] = result

# Write the updated results back to the output file
shared_utils.write_json_to_file(results, shared_utils.RESULT_FILE_PATH)
src/owlette_scout.py
theexperiential/Owlette
import shared_utils
import os
import win32serviceutil
import win32service
import win32event
import win32process
import win32profile
import win32ts
import win32con
import servicemanager
import logging
import psutil
import time
import json
import datetime

"""
To install/run this as a service, 
switch to the current working directory in 
an Administrator Command Prompt & run:
python owlette_service.py install | start | stop | remove
"""

# Constants
LOG_FILE_PATH = shared_utils.get_path('../logs/service.log')
MAX_RELAUNCH_ATTEMPTS = 3
SLEEP_INTERVAL = 10
TIME_TO_INIT = 60

# Utility functions
class Util:

    # Initialize results file
    @staticmethod
    def initialize_results_file():
        with open(shared_utils.RESULT_FILE_PATH, 'w') as f:
            json.dump({}, f)

    # Check if a Process ID (PID) is running
    @staticmethod
    def is_pid_running(pid):
        try:
            process = psutil.Process(pid)
            return True
        except psutil.NoSuchProcess:
            return False

    @staticmethod
    def get_process_name(process):
        return process.get('name', 'Error retrieving process name')


# Main Owlette Windows Service logic
class OwletteService(win32serviceutil.ServiceFramework):
    _svc_name_ = 'OwletteService'
    _svc_display_name_ = 'Owlette Service'

    def __init__(self, args):
        win32serviceutil.ServiceFramework.__init__(self, args)

        # Initialize logging and shared resources
        shared_utils.initialize_logging("service")
        Util.initialize_results_file()

        # Upgrade JSON config to latest version
        shared_utils.upgrade_config()

        self.hWaitStop = win32event.CreateEvent(None, 0, 0, None)
        self.is_alive = True
        self.tray_icon_pid = None
        self.relaunch_attempts = {} # Restart attempts for each process
        self.first_start = True # First start of this service
        self.last_started = {} # Last time a process was started
        self.results = {} # App process response esults
        self.current_time = datetime.datetime.now()

    # On service stop
    def SvcStop(self):
        self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
        self.is_alive = False
        self.terminate_tray_icon()
        win32event.SetEvent(self.hWaitStop)

    # While service runs
    def SvcDoRun(self):
        try:
            servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE,
                  servicemanager.PYS_SERVICE_STARTED,
                  (self._svc_name_, ''))
            self.main()
        except Exception as e:
            logging.error(f"An unhandled exception occurred: {e}")

    # Log errors
    def log_and_notify(self, process, reason):
        process_name = Util.get_process_name(process)

        # Logging
        logging.error(reason)

        # Slack
        if shared_utils.read_config(['slack', 'enabled']):
            self.send_notification('slack', process_name, reason)
        
        # Email
        if shared_utils.read_config(['gmail', 'enabled']):
            self.send_notification('gmail', process_name, reason)
    
    # Terminate the tray icon process if it exists
    def terminate_tray_icon(self):
        if self.tray_icon_pid:
            try:
                psutil.Process(self.tray_icon_pid).terminate()
            except psutil.NoSuchProcess:
                logging.error("No such process to terminate.")
            except psutil.AccessDenied:
                logging.error("Access denied while trying to terminate the process.")
            except Exception as e:
                logging.error(f"An unexpected error occurred while terminating the process: {e}")

    # Send gmail/slack notification
    def send_notification(self, notification_type, process_name, reason):
        try:
            if notification_type == 'gmail':
                script_path = shared_utils.get_path('owlette_gmail.py')
            elif notification_type == 'slack':
                script_path = shared_utils.get_path('owlette_slack.py')
            else:
                logging.error(f"Unknown notification type: {notification_type}")
                return

            self.launch_python_script_as_user(
                script_path,
                f'--process_name "{process_name}" --reason "{reason}"'
            )
            logging.info(f"{notification_type.capitalize()} notification sent for process {process_name}")
        except Exception as e:
            logging.error(f"Could not send {notification_type} notification for process {process_name}. Error: {e}")


    # Start a python script as a user
    def launch_python_script_as_user(self, script_name, args=None):
        try:
            self.startup_info.wShowWindow = win32con.SW_HIDE
            command_line = f'python "{shared_utils.get_path(script_name)}" {args}' if args else f'python "{shared_utils.get_path(script_name)}"'
            #logging.info(command_line)
            _, _, pid, _ = win32process.CreateProcessAsUser(self.console_user_token,
                None,  # Application Name
                command_line,  # Command Line
                None,
                None,
                0,
                win32con.NORMAL_PRIORITY_CLASS,
                self.environment,  # To open in user's self.environment
                None,
                self.startup_info)
            if 'owlette_tray.py' in script_name:
                self.tray_icon_pid = pid
            return True
        except Exception as e:
            logging.error(f"Failed to start process: {e}")
            return False

    # Start a Windows process as a user
    def launch_process_as_user(self, process):
        # Get visibility, default is shown
        visibility = process.get('visibility', 'Show')

        # Map process priority, default is normal
        priority = process.get('priority', 'Normal')
        priority_mapping = {
            "Low": win32con.IDLE_PRIORITY_CLASS,
            #"Below Normal": win32con.BELOW_NORMAL_PRIORITY_CLASS, # doesn't seem to work?
            "Normal": win32con.NORMAL_PRIORITY_CLASS,
            #"Above Normal": win32con.ABOVE_NORMAL_PRIORITY_CLASS, # doesn't seem to work?
            "High": win32con.HIGH_PRIORITY_CLASS,
            "Realtime": win32con.REALTIME_PRIORITY_CLASS
        }
        priority_class = priority_mapping.get((priority), win32con.NORMAL_PRIORITY_CLASS)

        # Show or hide window!
        self.startup_info.wShowWindow = win32con.SW_SHOW if visibility == 'Show' else win32con.SW_HIDE

        # Fetch and verify executable path
        exe_path = process.get('exe_path', '')
        try:
            if not os.path.isfile(exe_path):
                raise FileNotFoundError('Executable path not found!')
        except Exception as e:
            logging.error(f'Error: {e}')
            return None

        # Fetch file path
        file_path = process.get('file_path', '')
        # If file path, add double quotes, else leave as-is (cmd args)
        file_path = f"{file_path}" if os.path.isfile(file_path) else file_path
        logging.info(f"Starting {exe_path}{' ' if file_path else ''}{file_path}...")

        # Build the command line
        command_line = f'"{exe_path}" {file_path}' if file_path else exe_path
        
        # Start the process
        process_info = win32process.CreateProcessAsUser(
            self.console_user_token,
            None,  # Application Name
            command_line,  # Command Line
            None, # Process Attributes
            None, # Thread Attributes
            0, # Inherit handles
            priority_class, # Creation flags
            self.environment,  # To open in user's self.environment
            None, # Current directory
            self.startup_info)

        # Get PID
        pid = process_info[2]

        # Get the current Unix timestamp
        self.current_timestamp = int(time.time())

        # Read existing results from the output file
        try:
            self.results = shared_utils.read_json_from_file(shared_utils.RESULT_FILE_PATH)
        except:
            logging.error('JSON read error')
            self.results = {}

        # Initialize the entry for the PID if it doesn't exist
        if str(pid) not in self.results:
            self.results[str(pid)] = {}

        # Record the timestamp for the newly started process
        self.results[str(pid)]['timestamp'] = self.current_timestamp

        # Add process list ID
        self.results[str(pid)]['id'] = process['id']

        # Update status
        self.results[str(pid)]['status'] = 'LAUNCHING'

        # Write the updated results back to the output file
        try:
            shared_utils.write_json_to_file(self.results, shared_utils.RESULT_FILE_PATH)
        except:
            logging.error('JSON write error')


        return pid

    # Check if process has been restarted more than n times already
    def reached_max_relaunch_attempts(self, process):
        process_name = Util.get_process_name(process)
        try:
            attempts = self.relaunch_attempts.get(process_name, 0 if self.first_start else 1)

            process_list_id = shared_utils.fetch_process_id_by_name(process_name, shared_utils.read_config())
            relaunches_to_attempt = int(shared_utils.read_config(keys=['relaunch_attempts'], process_list_id=process_list_id))
            if not relaunches_to_attempt:
                relaunches_to_attempt = MAX_RELAUNCH_ATTEMPTS

            # Check if restart prompt is running
            if not shared_utils.is_script_running('prompt_restart.py'):
                # If attempts are less than or equal to the relaunch attempts, log it
                if 0 < attempts <= relaunches_to_attempt:
                    self.log_and_notify(
                        process,
                        f'Process relaunch attempt: {attempts} of {relaunches_to_attempt}'
                    )
                # If this is more than the maximum number of attempts allowed
                if attempts > relaunches_to_attempt and relaunches_to_attempt != 0:
                    # If a restart prompt isn't already running, open one
                    started_restart_prompt = self.launch_python_script_as_user(
                        shared_utils.get_path('prompt_restart.py'),
                        None
                    )
                    if started_restart_prompt:
                        self.log_and_notify(
                            process,
                            f'Terminated {process_name} {relaunches_to_attempt} times. System reboot imminent'
                        )
                        # Reset the counter for this process
                        del self.relaunch_attempts[process_name]
                        return True
                    else:
                        logging.info('Failed to open restart prompt.')
            else:
                return True # If it's running, we've already reached the max attempts

            self.relaunch_attempts[process_name] = attempts + 1
            return False

        except Exception as e:
            logging.info(e)

    # Kill and restart a process
    def kill_and_relaunch_process(self, pid, process):
        # Ensure process has not exceeded maximum relaunch attempts
        process_name = Util.get_process_name(process)
        if not self.reached_max_relaunch_attempts(process):
            try:
                # Kill the process
                psutil.Process(pid).terminate()
                
                # Launch new process
                new_pid = self.launch_process_as_user(process)

                self.log_and_notify(
                    process,
                    f'Terminated PID {pid} and restarted with new PID {new_pid}'
                )
                # Status message
                shared_utils.update_process_status_in_json(new_pid, 'LAUNCHING')
                
                return new_pid

            except Exception as e:
                self.log_and_notify(
                    process,
                    f"Could not kill and restart process {pid}. Error: {e}"
                )
                return None

    # Attempt to launch the process if not running
    def handle_process_launch(self, process):
        # Ensure process has not exceeded maximum relaunch attempts
        if not self.reached_max_relaunch_attempts(process):
            process_list_id = process['id']
            delay = float(process.get('time_delay', 0))
            
            # Fetch the time to init (how long to give the app to initialize itself / start up)
            time_to_init = float(shared_utils.read_config(keys=['time_to_init'], process_list_id=process_list_id))

            # Give the app time to launch (if it's launching for the first time)
            last_info = self.last_started.get(process_list_id, {})
            last_time = last_info.get('time')
                        
            if last_time is None or (last_time is not None and (self.current_time - last_time).total_seconds() >= (time_to_init or TIME_TO_INIT)):
                # Delay starting of the app (if applicable)
                time.sleep(delay)

                # Attempt to start the process
                try:
                    pid = self.launch_process_as_user(process)
                except Exception as e:
                    logging.error(f"Could not start process {Util.get_process_name(process)}.\n {e}")
                
                # Update the last started time and PID
                self.last_started[process_list_id] = {'time': self.current_time, 'pid': pid}
                logging.info(f"PID {pid} started")
                
                return pid  # Return the new PID
            
            return None  # Return None if the process was not started

    # If process not responding, attempt to kill and relaunch
    def handle_unresponsive_process(self, pid, process):
        # Check JSON for process response status
        process_name = Util.get_process_name(process)
        try:
            responsive = self.results.get(str(pid), {}).get('responsive', True)
        except json.JSONDecodeError:
            logging.error("Failed to decode JSON from result file")
            responsive = True
        except Exception:
            logging.error("An unexpected error occurred")
            responsive = True

        # Attempt to kill and relaunch if unresponsive
        if not responsive:
            self.log_and_notify(
                process,
                f"Process {process_name} (PID {pid}) is not responding"
            )
            # Status message
            shared_utils.update_process_status_in_json(pid, 'STALLED')
            time.sleep(1)
            new_pid = self.kill_and_relaunch_process(pid, process)
            return new_pid
        return None

    # Main process handler
    def handle_process(self, process):
        process_list_id = process['id']
        last_info = self.last_started.get(process_list_id, {})
        last_pid = last_info.get('pid')
        
        # Launch process if this is the first startup
        if self.first_start:
            new_pid = self.handle_process_launch(process)

        else:
            # Check if process is running
            if last_pid and Util.is_pid_running(last_pid):
                # Launch scout to check if process is responsive
                self.launch_python_script_as_user(
                    shared_utils.get_path('owlette_scout.py'), 
                    str(last_pid)
                )
                new_pid = self.handle_unresponsive_process(last_pid, process)
                
                #  Everything is fine, keep calm and carry on
                if not new_pid:
                    shared_utils.update_process_status_in_json(last_pid, 'RUNNING')

            else:
                # Launch the process again if it isn't running
                new_pid = self.handle_process_launch(process)
        
        # Update last started info (for handling process startup timing)
        if new_pid:
            self.last_started[process_list_id] = {'time': self.current_time, 'pid': new_pid}

    # Main main
    def main(self):
        # Process startup info
        self.startup_info = win32process.STARTUPINFO()
        self.startup_info.dwFlags = win32process.STARTF_USESHOWWINDOW

        # Get token for logged-in user
        self.console_session_id = win32ts.WTSGetActiveConsoleSessionId()
        self.console_user_token = win32ts.WTSQueryUserToken(self.console_session_id)
        # Get self.environment for logged-in user
        self.environment = win32profile.CreateEnvironmentBlock(self.console_user_token, False)

        # The heart of Owlette
        while self.is_alive:
            # Start the tray icon script as a process (if it isn't running)
            tray_script = 'owlette_tray.py'
            if not shared_utils.is_script_running(tray_script):
                self.launch_python_script_as_user(tray_script)

            # Get the current time
            self.current_time = datetime.datetime.now()

            # Load in latest results from the output file
            content = shared_utils.read_json_from_file(shared_utils.RESULT_FILE_PATH)
            if content:
                self.results = content

            # Load in all processes in config json
            processes = shared_utils.read_config(['processes'])
            for process in processes:
                if process.get('autolaunch', False): # Default to False if not found
                    self.handle_process(process)

            if self.first_start:
                logging.info('Owlette initialized')

            self.first_start = False

            # Sleep for 10 seconds
            time.sleep(SLEEP_INTERVAL)

if __name__ == '__main__':
    win32serviceutil.HandleCommandLine(OwletteService)

src/owlette_service.py
theexperiential/Owlette
import keyring
import logging
import requests
import shared_utils
import argparse

# Load logging
shared_utils.initialize_logging("slack")

# Take an input of the app name that was restarted
parser = argparse.ArgumentParser(description='Send Slack notifications.')
parser.add_argument('--process_name', type=str, help='Name of the process to notify about')
parser.add_argument('--reason', type=str, help='Reason for the Slack notification')
args = parser.parse_args()
process_name = args.process_name
reason = args.reason

def get_cred(name, item):
    cred = keyring.get_password(name, item)
    if cred is None:
        logging.error("Windows credential not found")
        return None
    else:
        return cred

def slack_api_call(method, url, token, params=None, json=None):
    headers = {"Authorization": f"Bearer {token}"}
    response = requests.request(method, url, headers=headers, params=params, json=json)
    
    if response.status_code == 200:
        response_json = response.json()
        if response_json.get('ok'):
            return response_json
        else:
            logging.error(f"Slack API Error: {response_json.get('error')}")
            return None
    else:
        logging.error(f"HTTP Error: {response.status_code}")
        return None

def get_workspace_owner_id(token):
    url = "https://slack.com/api/users.list"
    response_json = slack_api_call("GET", url, token)
    
    if response_json:
        for user in response_json.get('members', []):
            if user.get('is_owner'):
                return user.get('id')
    return None

def invite_user_to_channel(token, channel_id, user_id):
    url = "https://slack.com/api/conversations.invite"
    data = {"channel": channel_id, "users": user_id}
    response_json = slack_api_call("POST", url, token, json=data)
    
    if response_json:
        logging.info(f"Successfully invited user {user_id} to channel {channel_id}")
    else:
        logging.error(f"Failed to invite user to channel")

def get_channel_id_by_name(token, channel_name):
    url = "https://slack.com/api/conversations.list"
    params = {"limit": 1000}  # Adjust as needed
    response_json = slack_api_call("GET", url, token, params=params)
    
    if response_json:
        for channel in response_json.get('channels', []):
            if channel.get('name') == channel_name:
                return channel.get('id')
    return None

def create_channel(token, channel_name):
    url = "https://slack.com/api/conversations.create"
    data = {"name": channel_name, "is_private": False}  # Set to True for a private channel
    response_json = slack_api_call("POST", url, token, json=data)
    
    if response_json:
        channel_id = response_json['channel']['id']
        logging.info(f"Successfully created channel {channel_id}")

        # Invite the workspace owner to this channel
        owner_id = get_workspace_owner_id(token)
        if owner_id:
            invite_user_to_channel(token, channel_id, owner_id)
        
        # Save Channel ID to Windows Credentials for future use
        keyring.set_password("Owlette", "ChannelID", channel_id)
        logging.info(f"Added Channel ID {channel_id} to Windows Credentials")
        
        return channel_id
    else:
        logging.info("Failed to create channel, checking if the name is already taken.")
        channel_id = get_channel_id_by_name(token, channel_name)
        
        if channel_id:
            # Save Channel ID to Windows Credentials for future use
            keyring.set_password("Owlette", "ChannelID", channel_id)
            logging.info(f"Added existing Channel ID {channel_id} to Windows Credentials")
            return channel_id
        else:
            logging.error("Failed to fetch or create the channel.")
            return None

def send_message(message_text):
    token = get_cred('Owlette', 'SlackBotUserOAuthToken')
    channel_id = get_cred('Owlette', 'ChannelID')
    if token and channel_id:
        url = "https://slack.com/api/chat.postMessage"

        # Get system info
        system_info = shared_utils.get_system_info()

        # Create the alert block
        alert_block = {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": f":owl: :loudspeaker: *Hoo!* @channel\n\n> {message_text}"
            }
        }

        # Create the metrics block
        metrics_block = {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": f":mag: *System Metrics*\n\n> - :computer: CPU Model: *{system_info['cpu_model']}*\n> - :zap: CPU Usage: *{system_info['cpu_usage']}%*\n> - :thermometer: Memory Used/Total: *{system_info['memory_used']} GB*/{system_info['memory_total']} GB\n> - :cd: Disk Used/Total: *{system_info['disk_used']} GB*/{system_info['disk_total']} GB\n> - :video_game: GPU Model: *{system_info['gpu_model']}*\n> - :film_frames: GPU VRAM Used/Total: *{system_info['gpu_info']} MB*/{system_info['gpu_total']} MB"
            }
        }


        # Create the message payload
        data = {
            "channel": channel_id,
            "blocks": [alert_block, metrics_block],
            "attachments": [
                {
                    "color": "#36a64f",  # You can change the color
                }
            ]
        }

        # Send the message
        response_json = slack_api_call("POST", url, token, json=data)
        
        if response_json:
            logging.info(f"Successfully sent message {message_text}. Response: {response_json}")
        else:
            logging.error(f"Failed to send message. Response: {response_json}")
        
        return channel_id

try:
    if process_name is not None and reason is not None:
        send_message(f":computer: Computer: *{shared_utils.get_hostname()}*\n> :carpentry_saw: Process: *{process_name}*\n> :pencil2: Status: *{reason}*")

except Exception as e:
    logging.error(e)
src/owlette_slack.py
theexperiential/Owlette
import shared_utils
import pystray
from pystray import MenuItem as item
from PIL import Image, ImageDraw
import subprocess
import logging
import os
import psutil
import ctypes
import sys
import winreg
import win32gui
import win32con

pid = None
start_on_login = True

# Function to create icon image
def create_image():
    width, height = 64, 64
    color1 = (255, 255, 255)
    color2 = (0, 0, 0)
    image = Image.new('RGB', (width, height), color1)
    dc = ImageDraw.Draw(image)
    dc.rectangle(
        (width // 2, 0, width, height // 2),
        fill=color2)
    dc.rectangle(
        (0, height // 2, width // 2, height),
        fill=color2)
    return image

# Function to check if process is running
def is_process_running(pid):
    if pid is None:
        return False
    try:
        process = psutil.Process(pid)
        return True if process.is_running() else False
    except psutil.NoSuchProcess:
        return False
    except Exception as e:
        logging.error(f"Failed to check if process is running: {e}")
        return False

# Function to open configuration
def open_config_gui(icon, item):
    global pid
    if not is_process_running(pid):
        try:
            process = subprocess.Popen(["pythonw", shared_utils.get_path('owlette_gui.py')])
            pid = process.pid
        except Exception as e:
            logging.error(f"Failed to open Owlette Configuration: {e}")
    else:
        try:
            # Assuming the window title contains "Owlette"
            hwnd = win32gui.FindWindow(None, shared_utils.WINDOW_TITLES.get("owlette_gui"))
            if hwnd:
                # Restore window if minimized.
                win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
                # Bring window to front.
                win32gui.SetForegroundWindow(hwnd)
        except Exception as e:
            logging.error(f"Failed to bring Owlette Configuration to the front: {e}")

# Function to exit
def exit_action(icon, item):
    try:
        for key, window_title in shared_utils.WINDOW_TITLES.items():
            # Try to close the configuration window if it's open
            hwnd = win32gui.FindWindow(None, window_title)
            if hwnd:
                # Close the window
                win32gui.PostMessage(hwnd, win32con.WM_CLOSE, 0, 0)
            
        # Stop the service
        ctypes.windll.shell32.ShellExecuteW(None, "runas", "cmd.exe", f"/c python {shared_utils.get_path('owlette_service.py')} stop", None, 0)
    except Exception as e:
        logging.error(f"Failed to stop service: {e}")
    icon.stop()


# Function to change the registry setting for the Windows Service
def on_select(icon, item):
    global start_on_login  # Declare global to modify it
    
    try:
        #logging.info(f"Checkbox state before action: {start_on_login}")

        # Check for admin rights
        is_admin = ctypes.windll.shell32.IsUserAnAdmin() != 0

        # Set the service start type
        start_type = "delayed-auto" if not start_on_login else "disabled"

        if not is_admin:
            # Re-run the command with admin rights
            ctypes.windll.shell32.ShellExecuteW(None, "runas", "cmd.exe", f"/k sc config OwletteService start= {start_type}", None, 0)
        else:
            subprocess.run('sc config OwletteService start= disabled', shell=True)

        start_on_login = not start_on_login  # Toggle the checkbox state
        #logging.info(f"Checkbox state after action: {start_on_login}")
        
        # Update menu
        icon.update_menu(generate_menu())

    except Exception as e:
        logging.error(f"Failed to change service startup type: {e}")


# Function to check the service status
def check_service_status():
    try:
        key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, 'SYSTEM\\CurrentControlSet\\Services\\OwletteService', 0, winreg.KEY_READ)
        start_type, _ = winreg.QueryValueEx(key, 'Start')
        winreg.CloseKey(key)
        return start_type == 0x2
    except Exception as e:
        logging.error(f"Failed to read service start type: {e}")
        return False

# Dynamically generate the menu
def generate_menu():
    return pystray.Menu(
        item(f'Version: {shared_utils.APP_VERSION}', lambda icon, item: None, enabled=False),  # Read-only item
        item('Open Config', open_config_gui),
        item('Start on Login', on_select, checked=lambda text: start_on_login),
        item('Exit', exit_action)
    )

def is_script_running(script_name):
    count = 0
    for process in psutil.process_iter(attrs=['pid', 'name', 'cmdline']):
        if 'python' in process.info['name']:
            cmdline = process.info.get('cmdline')
            if cmdline:
                if script_name in ' '.join(cmdline):
                    count += 1
    return count > 1

if __name__ == "__main__":
    # Initialize logging
    shared_utils.initialize_logging("tray")

    if not is_script_running('owlette_tray.py'): 
        # Create the system tray icon
        image = create_image()
        icon = pystray.Icon(
            "owlette_icon", 
            image, 
            "Owlette", 
            menu=generate_menu()
        )

        # Run the icon
        icon.run()

        logging.info('Exiting Tray icon...')

    else:
        logging.info('Tray icon is already running...')
        sys.exit(0)
src/owlette_tray.py
theexperiential/Owlette
import customtkinter as ctk
import os
import shared_utils

class PromptRestart:
    FRAME_COLOR = '#28292b'
    BUTTON_COLOR = '#374448'
    BUTTON_HOVER_COLOR = '#27424a'
    WINDOW_WIDTH = 400
    WINDOW_HEIGHT = 200

    def __init__(self, master):
        self.master = master
        self.master.title("Process repeatedly failing!")
        shared_utils.center_window(master, self.WINDOW_WIDTH, self.WINDOW_HEIGHT)
        self.master.lift()
        self.master.focus_force()
        

        self.paused = False
        self.time_left = 29

        self.setup_ui()

    def setup_ui(self):
        self.main_frame = ctk.CTkFrame(master=self.master, fg_color=self.FRAME_COLOR)
        self.main_frame.pack(fill='both', expand=True)

        self.label = ctk.CTkLabel(self.main_frame, text="The system will restart automatically in 0:30 seconds.", fg_color=self.FRAME_COLOR)
        self.label.pack(padx=10, pady=(20, 10))

        self.restart_button = ctk.CTkButton(self.main_frame, text="Restart Now", command=self.restart_now, fg_color=self.BUTTON_COLOR, hover_color=self.BUTTON_HOVER_COLOR)
        self.restart_button.pack(pady=10)

        self.pause_switch = ctk.CTkSwitch(self.main_frame, text="Pause", command=self.toggle_pause, fg_color=self.BUTTON_COLOR, onvalue="on", offvalue="off")
        self.pause_switch.pack(pady=10)

        self.cancel_button = ctk.CTkButton(self.main_frame, text="Cancel", command=self.cancel, fg_color=self.BUTTON_COLOR, hover_color=self.BUTTON_HOVER_COLOR)
        self.cancel_button.pack(pady=10)

        self.master.after(1000, self.countdown)

    def restart_now(self):
        print("Restarting")
        os.system('shutdown /r /t 1')
        self.master.quit()

    def cancel(self):
        print("Cancel")
        self.master.quit()

    def toggle_pause(self):
        self.paused = False if self.pause_switch.get() == 'off' else True
        if not self.paused:
            self.countdown()

    def countdown(self):
        if not self.paused:
            if self.time_left > 0:
                mins, secs = divmod(self.time_left, 60)
                time_str = f"{mins}:{secs}"
                self.label.configure(text=f"The system will restart automatically in {time_str} seconds.")
                self.time_left -= 1
                self.master.after(1000, self.countdown)
            else:
                self.restart_now()

if __name__ == "__main__":
    root = ctk.CTk()
    app = PromptRestart(root)
    root.mainloop()

src/prompt_restart.py
theexperiential/Owlette
import shared_utils
import customtkinter as ctk
import keyring
import logging
import tkinter.messagebox

class PromptSlackConfig:

    def __init__(self, master):
        self.master = master
        self.master.title(shared_utils.WINDOW_TITLES.get("prompt_slack_config"))
        #self.master.geometry("360x180")
        shared_utils.center_window(master, 420, 130)

        # Set a minimum size for the main frame
        #self.master.minsize(360, 200)

        # Create a layout/frame
        self.master.grid_rowconfigure(0, weight=1)
        self.master.grid_columnconfigure(0, weight=1)
        self.main_frame = ctk.CTkFrame(self.master, fg_color=shared_utils.FRAME_COLOR)
        self.main_frame.grid(row=0, column=0, sticky="nsew")

        # Configure the main_frame grid to center its children
        self.main_frame.grid_rowconfigure(0, weight=1)
        self.main_frame.grid_rowconfigure(1, weight=1)
        self.main_frame.grid_rowconfigure(2, weight=1)
        self.main_frame.grid_rowconfigure(3, weight=1)
        self.main_frame.grid_columnconfigure(0, weight=1)
        self.main_frame.grid_columnconfigure(1, weight=1)
        self.main_frame.grid_columnconfigure(2, weight=1)
        self.main_frame.grid_columnconfigure(3, weight=1)

        # Create a label and entry for "Bot User OAuth Token"
        self.bot_user_label = ctk.CTkLabel(self.main_frame, text="Slack Bot User OAuth Token:", fg_color=shared_utils.FRAME_COLOR)
        self.bot_user_label.grid(row=0, column=1, sticky='e', padx=5, pady=(20,5))
        self.bot_user_entry = ctk.CTkEntry(self.main_frame, placeholder_text="Enter Token", width=200)
        self.bot_user_entry.grid(row=0, column=2, sticky='w', padx=5, pady=(20,5))

        # Create a submit button
        self.submit_button = ctk.CTkButton(self.main_frame, text="Submit", command=self.submit)
        self.submit_button.grid(row=2, column=1, columnspan=2, padx=5, pady=(0, 10))

    def show_error(self, message):
        tkinter.messagebox.showerror("Error", message)

    def submit(self):
        bot_user_oauth_token = self.bot_user_entry.get()

        # Check if the token is empty
        if not bot_user_oauth_token:
            self.show_error("No token entered. Please enter a valid token.")
            return

        # Check if the token starts with "xoxb-"
        if not bot_user_oauth_token.startswith("xoxb-"):
            self.show_error("Incorrect token entered. Please enter a valid token (begins with 'xoxb-').")
            return

        # Store these securely using keyring
        keyring.set_password("Owlette", "SlackBotUserOAuthToken", bot_user_oauth_token)
        logging.info("Added Slack Bot User OAuth Token to Windows Credentials")
        # Return this value to the main GUI process (owlette_gui.py)
        print(bot_user_oauth_token)
        self.master.destroy()

if __name__ == "__main__":
    root = ctk.CTk()
    app = PromptSlackConfig(root)
    root.mainloop()
src/prompt_slack_config.py
theexperiential/Owlette
import keyring

try:
    keyring.delete_password("Owlette", "GmailRefreshToken")
    keyring.delete_password("Owlette", "SlackBotUserOAuthToken")
    keyring.delete_password("Owlette", "ChannelID")
    print("Credentials removed successfully.")
except keyring.errors.PasswordDeleteError:
    print("Failed to remove one or more credentials.")

src/remove_creds.py
theexperiential/Owlette
import os
import json
import logging
from logging.handlers import RotatingFileHandler
import ctypes
import socket
from packaging import version
import psutil
import GPUtil
import platform
import subprocess
import threading
import psutil

# GLOBAL VARS

APP_VERSION = '0.4.0b'
CONFIG_VERSION = '1.2.0'
FRAME_COLOR = '#28292b'
BUTTON_COLOR = '#374448'
BUTTON_HOVER_COLOR = '#27424a'
BUTTON_IMPORTANT_COLOR = '#2d5e6c'
WINDOW_TITLES = {
    "owlette_gui": "Owlette Configuration", 
    "prompt_slack_config": "Connect to Slack",
    "prompt_restart": "Process repeatedly failing!"
}
SERVICE_NAME = 'OwletteService'


# OS
# Initialize a global lock
json_lock = threading.Lock()

# Return the hostname of the machine where the script is running
def get_hostname():
    return socket.gethostname()

def get_path(filename=None):
    # Get the directory of the currently executing script
    path = os.path.dirname(os.path.realpath(__file__))

    # Build the full path to the file name
    if filename is not None:
        path = os.path.join(path, filename)

    # Normalize the path
    path = os.path.normpath(path)
    
    return path

def is_script_running(script_name):
    for process in psutil.process_iter(attrs=['pid', 'name', 'cmdline']):
        print(process.info['name'])
        print(script_name)
        if 'python' in process.info['name']:
            if script_name in ' '.join(process.info['cmdline']):
                return True
    return False

# PATHS
CONFIG_PATH = get_path('../config/config.json')
RESULT_FILE_PATH = get_path('../tmp/app_states.json')

# LOGGING
# Initialize logging with a rotating file handler
def initialize_logging(log_file_name, level=logging.INFO):
    log_file_path = get_path(f'../logs/{log_file_name}.log')
    
    # Clear the log file
    with open(log_file_path, 'w'):
        pass
    
    # Create a formatter for the log messages
    log_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
    
    # Create a handler that writes log messages to a file, with a maximum
    # log file size of 5 MB, keeping 2 old log files.
    log_handler = RotatingFileHandler(log_file_path, mode='a', maxBytes=5*1024*1024, backupCount=2, encoding=None, delay=0)
    
    # Set the formatter for the handler
    log_handler.setFormatter(log_formatter)
    
    # Create the logger and set its level
    logger = logging.getLogger()
    logger.setLevel(level)
    
    # Add the handler to the logger
    logger.addHandler(log_handler)
    
    # Log an initial message
    logging.info(f"Starting {log_file_name}...")

# CONFIG JSON

# Reads a JSON configuration file from a given path and optionally updates 
# an emails_to_entry list with email addresses from the configuration.
def load_config(emails_to_entry=None):
    try:
        config = read_json_from_file(CONFIG_PATH)
        if emails_to_entry is not None:
            emails_to_entry.insert(0, ', '.join(config['gmail']['to']))
        return config
        
    except FileNotFoundError as e:
        logging.error(f"Failed to load config: {e}")
        return generate_config_file()

# Writes a given configuration to a JSON file at a specified path. 
# If emails_to_entry is provided, the function updates the email addresses 
# in the configuration before saving.
def save_config(config=None, emails_to_entry=None):
    if config is None:
        config = read_json_from_file(CONFIG_PATH)
    
    if emails_to_entry is not None:
        config['gmail']['to'] = [email.strip() for email in emails_to_entry.get().split(',')]
    
    write_json_to_file(config, CONFIG_PATH)
  
# Maintain compatibility from JSON config versions < 1.1.0
def upgrade_config():
    # Directly read the original config file
    config = read_json_from_file(CONFIG_PATH)
    if config:
        # Check if 'version' key exists and its value
        current_version = config.get('version', '0.0.0')

        # If version is less than 1.1.0, apply changes
        if version.parse(current_version) < version.parse(CONFIG_VERSION):
            # Add or update the 'version' key to latest
            config['version'] = CONFIG_VERSION

            # Update other keys as needed
            if 'email' in config:
                config['gmail'] = config.pop('email')
                config['gmail']['enabled'] = True

            # Update autostart to autolaunch
            for process in config['processes']:
                if 'autostart_process' in process:
                    process['autolaunch'] = process.pop('autostart_process')
                elif 'autolaunch_process' in process:
                    process['autolaunch'] = process.pop('autolaunch_process')

                # Ensure all necessary keys are in each process object
                for key in ['id', 'name', 'exe_path', 'file_path', 'time_delay', 'time_to_init', 'relaunch_attempts', 'autolaunch', 'visibility', 'priority']:
                    if key == 'visibility':
                        process.setdefault(key, 'Show')
                    elif key == 'priority':
                        process.setdefault(key, 'Normal')
                    else:
                        process.setdefault(key, '')

            # Reorder the keys so that 'version' is at the top
            ordered_config = {'version': config['version']}
            for key in config:
                if key != 'version':
                    ordered_config[key] = config[key]

            # Write the updated config back to the file
            write_json_to_file(ordered_config, CONFIG_PATH)


    else:
        # if there are problems, just regenerate the config file
        new_config = generate_config_file()
        
        # Write the updated config back to the file
        write_json_to_file(new_config, CONFIG_PATH)

# Read a JSON file and returns its content as a Python dictionary
def read_json_from_file(file_path):
    with json_lock:
        try:
            with open(file_path, 'r') as f:
                return json.load(f)
        except FileNotFoundError:
            logging.info(f"{file_path} not found.")
            return None
        except json.JSONDecodeError:
            logging.error("Failed to decode JSON.")
            return None
        except Exception as e:
            logging.error(f"An error occurred while reading the file: {e}")
            return None

# Writes a Python dictionary to a JSON file
def write_json_to_file(data, file_path):
    with json_lock:
        try:
            with open(file_path, 'w') as f:
                json.dump(data, f, indent=4)
        except Exception as e:
            logging.error(f"An error occurred while writing to the file: {e}")

# Generate a default configuration file, optionally merging with an existing one
def generate_config_file(existing_config=None):
    default_config = {
        "version": CONFIG_VERSION, 
        "processes": [], 
        "gmail": {
            "enabled": False, 
            "to": []
        },
        "slack": {
            "enabled": False
        }
    }
    
    if existing_config is None:
        return default_config

    # Update only missing keys
    for key, value in default_config.items():
        if key not in existing_config:
            existing_config[key] = value

    return existing_config

# Read specific keys from the configuration file or a specific process by its ID
def read_config(keys=None, process_list_id=None):
    config = read_json_from_file(CONFIG_PATH)

    # If process_list_id is provided, find the corresponding process
    if process_list_id:
        for process in config['processes']:
            if process['id'] == process_list_id:
                if keys:
                    item = process
                    for key in keys:
                        item = item.get(key, None)
                        if item is None:
                            return None
                    return item
                else:
                    return process

    # If keys are provided, traverse the config to find the value
    elif keys:
        item = config
        for key in keys:
            item = item.get(key, None)
            if item is None:
                return None
        return item

    return config

# Write a specific value to a specific key in the configuration file
def write_config(keys, value):
    config = read_json_from_file(CONFIG_PATH)

    # Traverse the config dictionary using the keys to find the item to update
    item = config
    for key in keys[:-1]:
        item = item.get(key, {})

    # Update the value
    item[keys[-1]] = value

    write_json_to_file(config, CONFIG_PATH)

# PROCESSES

def fetch_pid_by_id(target_id):
    data = read_json_from_file(RESULT_FILE_PATH)
    
    # Filter out the processes that match the target_id
    matching_processes = {pid: info for pid, info in data.items() if info['id'] == target_id}
    
    if not matching_processes:
        print(f"No processes found with id: {target_id}")
        return None
    
    # Find the pid of the process with the newest timestamp
    newest_pid = max(matching_processes.keys(), key=lambda pid: matching_processes[pid]['timestamp'])
    
    return newest_pid

def update_process_status_in_json(pid, new_status):
    data = read_json_from_file(RESULT_FILE_PATH)
    data[str(pid)]['status'] = new_status
    write_json_to_file(data, RESULT_FILE_PATH)

def fetch_process_by_id(id, data):
    return next((process for process in data['processes'] if process['id'] == id), None)

def fetch_process_name_by_id(id, data):
    process = next((process for process in data['processes'] if process['id'] == id), None)
    return process['name'] if process else None   

def fetch_process_id_by_name(name, data):
    process = next((process for process in data['processes'] if process['name'] == name), None)
    return process['id'] if process else None

def get_process_index(selected_process_id):
    return next((i for i, p in enumerate(read_config()['processes']) if p['id'] == selected_process_id), None)

# WINDOWS / UI

def get_scaling_factor():
    hdc = ctypes.windll.user32.GetDC(0)
    LOGPIXELSX = 88
    actual_dpi = ctypes.windll.gdi32.GetDeviceCaps(hdc, LOGPIXELSX)
    ctypes.windll.user32.ReleaseDC(0, hdc)
    return actual_dpi / 96.0  # 96 DPI is the standard DPI, so we divide the actual by 96 to get the scaling factor

def center_window(root, width, height):
    scaling_factor = get_scaling_factor()

    # Get screen width and height
    screen_width = root.winfo_screenwidth() * scaling_factor
    screen_height = root.winfo_screenheight() * scaling_factor

    # Calculate position x and y coordinates
    x = (screen_width / 2) - (width * scaling_factor / 2)
    y = (screen_height / 2) - (height * scaling_factor / 2)
    root.geometry(f'{int(width)}x{int(height)}+{int(x)}+{int(y)}')
    root.minsize(width, height)

# METRICS
def get_cpu_name():
    try:
        cpu_name = subprocess.check_output('wmic cpu get name', shell=True, text=True, stderr=subprocess.STDOUT).strip().split('\n')[-1]
        return cpu_name
    except Exception as e:
        print(f"An error occurred: {e}")
        return None

def get_system_info():
    # Get system information
    cpu_info = get_cpu_name()
    if not cpu_info:
        # Revert to platform info
        cpu_info = platform.processor()
    cpu_usage = psutil.cpu_percent()
    memory_info = psutil.virtual_memory()
    disk_info = psutil.disk_usage('/')
    gpus = GPUtil.getGPUs()
    gpu_info = gpus[0] if gpus else "No GPU detected"

    # Convert bytes to gigabytes
    bytes_to_gb = lambda x: round(x / (1024 ** 3), 2)

    return {
        'cpu_model': cpu_info,
        'cpu_usage': cpu_usage,
        'memory_used': bytes_to_gb(memory_info.used),
        'memory_total': bytes_to_gb(memory_info.total),
        'disk_used': bytes_to_gb(disk_info.used),
        'disk_total': bytes_to_gb(disk_info.total),
        'gpu_model': gpu_info.name if gpu_info else 'N/A',
        'gpu_info': gpu_info.memoryUsed if gpu_info else 'N/A',
        'gpu_total': gpu_info.memoryTotal if gpu_info else 'N/A'
    }
src/shared_utils.py
theexperiential/Owlette
import ctypes
import sys
import subprocess
import shared_utils

def is_admin():
    try:
        return ctypes.windll.shell32.IsUserAnAdmin()
    except:
        return False

def run_with_admin_privileges():
    if not is_admin():
        # Re-run the script with administrative privileges
        ctypes.windll.shell32.ShellExecuteW(None, "runas", sys.executable, " ".join(sys.argv), None, 1)
    else:
        # The script is running with administrative privileges, so launch the original script
        svc = shared_utils.get_path() + '/owlette_service.py'
        command = f'python {svc} start'
        subprocess.run(command, shell=True)

if __name__ == "__main__":
    run_with_admin_privileges()

src/start_service.py
theexperiential/Owlette