diff --git a/backend/.gitignore b/backend/.gitignore index 68eedbc..cab15ae 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -6,3 +6,4 @@ dist *.spec *.egg-info .cache +public/icons/programs/* diff --git a/backend/public/attributions.txt b/backend/public/attributions.txt new file mode 100644 index 0000000..1380cf5 --- /dev/null +++ b/backend/public/attributions.txt @@ -0,0 +1 @@ +default_prog_windows.svg: https://iconoir.com/ (Windows Icon) \ No newline at end of file diff --git a/backend/public/icons/default_prog_windows.svg b/backend/public/icons/default_prog_windows.svg new file mode 100644 index 0000000..e96d626 --- /dev/null +++ b/backend/public/icons/default_prog_windows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index ddd7010..4001ad7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,4 +3,5 @@ uvicorn pydantic spotipy pyinstaller -pillow \ No newline at end of file +pillow +pywin32 \ No newline at end of file diff --git a/backend/src/__pycache__/apps_interface.cpython-311.pyc b/backend/src/__pycache__/apps_interface.cpython-311.pyc new file mode 100644 index 0000000..9b426ee Binary files /dev/null and b/backend/src/__pycache__/apps_interface.cpython-311.pyc differ diff --git a/backend/src/__pycache__/extract_icon.cpython-311.pyc b/backend/src/__pycache__/extract_icon.cpython-311.pyc new file mode 100644 index 0000000..d5bee32 Binary files /dev/null and b/backend/src/__pycache__/extract_icon.cpython-311.pyc differ diff --git a/backend/src/__pycache__/main.cpython-311.pyc b/backend/src/__pycache__/main.cpython-311.pyc index 291653a..c61dedf 100644 Binary files a/backend/src/__pycache__/main.cpython-311.pyc and b/backend/src/__pycache__/main.cpython-311.pyc differ diff --git a/backend/src/apps_interface.py b/backend/src/apps_interface.py new file mode 100644 index 0000000..3f7ee68 --- /dev/null +++ b/backend/src/apps_interface.py @@ -0,0 +1,182 @@ +import subprocess +import platform +import json +import os +import src.extract_icon as extract_icon +import configparser +import shutil +import pythoncom + +from PIL import Image +from src.cmd_types import * + + +def start_app(app: str): + if platform.system() == "Windows": + command = f'start "" "{app}"' + subprocess.Popen(command, shell=True) + else: + raise NotImplementedError + + +def get_commands(): + pythoncom.CoInitialize() + + commands = [] + commands += get_prog_commands() + + pythoncom.CoUninitialize() + return commands + + +def get_prog_commands(): + # opens start menu and gets all programs + if platform.system() == "Windows": + global_path = os.path.join( + os.environ["PROGRAMDATA"], + "Microsoft", + "Windows", + "Start Menu", + ) + local_path = os.path.join( + os.environ["APPDATA"], + "Microsoft", + "Windows", + "Start Menu", + ) + + print("System Start Menu Contents:") + shortcuts = list_shortcuts_windows(global_path) + list_shortcuts_windows( + local_path + ) + + # print(shortcuts) + + # Clear out shortcuts with duplicate names + names = [] + unique_shortcuts = [] + for shortcut in shortcuts: + trimmed = shortcut["name"].strip() + + if trimmed in names: + print("WARNING: Duplicate shortcut name found: " + shortcut["name"]) + else: + names.append(trimmed) + unique_shortcuts.append(shortcut) + + cmds = [] + + for shortcut in unique_shortcuts: + # simple_print(shortcut) + + # Get icon + icon_path = "" + if shortcut["icon"] != "": + icon_path = shortcut["icon"] + elif shortcut["path"].endswith(".exe"): + icon_path = shortcut["path"] + + dest_path, success = load_icon_from_resource_windows( + icon_path, shortcut["name"] + ) + # simple_print(icon_path) + + remote_icon_path = os.path.join("/", "icons", "default_prog_windows.svg") + if success: + remote_icon_path = dest_path.replace( + os.path.join(os.getcwd(), "public"), "" + ) + + cmds.append( + Command( + title=f"Run: {shortcut['name']}", + command=lambda path=shortcut["path"]: start_app(path), + description="", + icon=remote_icon_path, + ) + ) + + return cmds + else: + raise NotImplementedError + + +def load_icon_from_resource_windows(path, fname): + success = False + dest_path = "" + if path.endswith(".exe"): + try: + icon = extract_icon.extract_icon(path, extract_icon.IconSize.LARGE) + print(icon) + + # Store icon in public/icons/programs + dest_path = os.path.join( + os.getcwd(), "public", "icons", "programs", fname + ".ico" + ) + icon.save(dest_path) + + print("Saving icon for " + path + " to " + dest_path + ".") + success = True + + except Exception as e: + print(f"Error extracting icon for {path}: {e}") + + elif path.endswith(".ico"): + dest_path = os.path.join( + os.getcwd(), + "public", + "icons", + "programs", + fname + ".ico", + ) + + try: + shutil.copy( + path, + dest_path, + ) + success = True + except Exception as e: + print(f"Error copying icon for {path}: {e}") + + return dest_path, success + + +def list_shortcuts_windows(directory): + import win32com.client + + out = [] + + """ + Recursively lists all shortcuts in the given directory and writes them to a log file. + """ + if os.path.exists(directory): + for item in os.listdir(directory): + full_path = os.path.join(directory, item) + if os.path.isdir(full_path): + # Recursively search in directories + out = out + list_shortcuts_windows(full_path) + elif item.lower().endswith(".lnk") or item.lower().endswith(".url"): + icon = "" # If empty, means we extract from executable + + if item.lower().endswith(".lnk"): + pywin_client = win32com.client.Dispatch("WScript.Shell") + shortcut = pywin_client.CreateShortCut(full_path) + real_path = shortcut.Targetpath + + elif item.lower().endswith(".url"): + config = configparser.ConfigParser() + config.read(full_path) + + data = dict(config.items("InternetShortcut")) + print(data) + real_path = data["url"] + + if "iconfile" in data: + icon = data["iconfile"] + + name = item[:-4].encode("utf-8", "replace").decode("utf-8") + + out += [{"name": name, "path": real_path, "icon": icon}] + + return out diff --git a/backend/src/extract_icon.py b/backend/src/extract_icon.py new file mode 100644 index 0000000..1cd61ef --- /dev/null +++ b/backend/src/extract_icon.py @@ -0,0 +1,155 @@ +# Attribs: https://pythonassets.com/posts/extract-icon-from-executable-file-windows/ + +from ctypes import Array, byref, c_char, memset, sizeof +from ctypes import c_int, c_void_p, POINTER +from ctypes.wintypes import * +from enum import Enum +import ctypes + +from PIL import Image + + +BI_RGB = 0 +DIB_RGB_COLORS = 0 + + +class ICONINFO(ctypes.Structure): + _fields_ = [ + ("fIcon", BOOL), + ("xHotspot", DWORD), + ("yHotspot", DWORD), + ("hbmMask", HBITMAP), + ("hbmColor", HBITMAP), + ] + + +class RGBQUAD(ctypes.Structure): + _fields_ = [ + ("rgbBlue", BYTE), + ("rgbGreen", BYTE), + ("rgbRed", BYTE), + ("rgbReserved", BYTE), + ] + + +class BITMAPINFOHEADER(ctypes.Structure): + _fields_ = [ + ("biSize", DWORD), + ("biWidth", LONG), + ("biHeight", LONG), + ("biPlanes", WORD), + ("biBitCount", WORD), + ("biCompression", DWORD), + ("biSizeImage", DWORD), + ("biXPelsPerMeter", LONG), + ("biYPelsPerMeter", LONG), + ("biClrUsed", DWORD), + ("biClrImportant", DWORD), + ] + + +class BITMAPINFO(ctypes.Structure): + _fields_ = [ + ("bmiHeader", BITMAPINFOHEADER), + ("bmiColors", RGBQUAD * 1), + ] + + +shell32 = ctypes.WinDLL("shell32", use_last_error=True) +user32 = ctypes.WinDLL("user32", use_last_error=True) +gdi32 = ctypes.WinDLL("gdi32", use_last_error=True) + +gdi32.CreateCompatibleDC.argtypes = [HDC] +gdi32.CreateCompatibleDC.restype = HDC +gdi32.GetDIBits.argtypes = [HDC, HBITMAP, UINT, UINT, LPVOID, c_void_p, UINT] +gdi32.GetDIBits.restype = c_int +gdi32.DeleteObject.argtypes = [HGDIOBJ] +gdi32.DeleteObject.restype = BOOL +shell32.ExtractIconExW.argtypes = [LPCWSTR, c_int, POINTER(HICON), POINTER(HICON), UINT] +shell32.ExtractIconExW.restype = UINT +user32.GetIconInfo.argtypes = [HICON, POINTER(ICONINFO)] +user32.GetIconInfo.restype = BOOL +user32.DestroyIcon.argtypes = [HICON] +user32.DestroyIcon.restype = BOOL + + +class IconSize(Enum): + SMALL = 1 + LARGE = 2 + + @staticmethod + def to_wh(size: "IconSize") -> tuple[int, int]: + """ + Return the actual (width, height) values for the specified icon size. + """ + size_table = {IconSize.SMALL: (16, 16), IconSize.LARGE: (32, 32)} + return size_table[size] + + +def extract_icon(filename: str, size: IconSize) -> Image: + """ + Extract the icon from the specified `filename`, which might be + either an executable or an `.ico` file. + """ + dc: HDC = gdi32.CreateCompatibleDC(0) + if dc == 0: + raise ctypes.WinError() + + hicon: HICON = HICON() + extracted_icons: UINT = shell32.ExtractIconExW( + filename, + 0, + byref(hicon) if size == IconSize.LARGE else None, + byref(hicon) if size == IconSize.SMALL else None, + 1, + ) + print(extracted_icons) + if extracted_icons != 1: + raise ctypes.WinError() + + def cleanup() -> None: + if icon_info.hbmColor != 0: + gdi32.DeleteObject(icon_info.hbmColor) + if icon_info.hbmMask != 0: + gdi32.DeleteObject(icon_info.hbmMask) + user32.DestroyIcon(hicon) + + icon_info: ICONINFO = ICONINFO(0, 0, 0, 0, 0) + if not user32.GetIconInfo(hicon, byref(icon_info)): + cleanup() + raise ctypes.WinError() + + w, h = IconSize.to_wh(size) + bmi: BITMAPINFO = BITMAPINFO() + memset(byref(bmi), 0, sizeof(bmi)) + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER) + bmi.bmiHeader.biWidth = w + bmi.bmiHeader.biHeight = -h + bmi.bmiHeader.biPlanes = 1 + bmi.bmiHeader.biBitCount = 32 + bmi.bmiHeader.biCompression = BI_RGB + bmi.bmiHeader.biSizeImage = w * h * 4 + bits = ctypes.create_string_buffer(bmi.bmiHeader.biSizeImage) + copied_lines = gdi32.GetDIBits( + dc, icon_info.hbmColor, 0, h, bits, byref(bmi), DIB_RGB_COLORS + ) + if copied_lines == 0: + cleanup() + raise ctypes.WinError() + + # My code + # Map from BGRA -> RGBA + # Function to swap red and blue bytes in BGRA to get RGBA + def swap_red_blue(bgra_buffer, size): + for i in range(0, size, 4): + # Swap the red and blue bytes (first and third bytes) + bgra_buffer[i], bgra_buffer[i + 2] = bgra_buffer[i + 2], bgra_buffer[i] + + # Call the function to modify the bits buffer + swap_red_blue(bits, bmi.bmiHeader.biSizeImage) + + mode = "RGBA" + image = Image.frombuffer(mode, (w, h), bits, "raw", mode, 0, 1) + + cleanup() + return image diff --git a/backend/src/main.py b/backend/src/main.py index 294d2d4..f278b1f 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -5,6 +5,7 @@ import src.web_interface as web_interface import src.cmd_interface as cmd_interface +import src.apps_interface as apps_interface import uvicorn import subprocess @@ -20,7 +21,7 @@ base_commands = CommandList(commands=[]) -contributors = [web_interface, spotify_core, cmd_interface] +contributors = [web_interface, spotify_core, cmd_interface, apps_interface] def build_commands_list(): @@ -35,7 +36,9 @@ def build_commands_list(): # Assert that command titles are unique titles = [] for command in out.commands: - assert command.title not in titles + if command.title in titles: + raise ValueError(f"Duplicate command title: {command.title}") + titles.append(command.title) return out diff --git a/backend/uvicorn.log b/backend/uvicorn.log deleted file mode 100644 index be6b3f1..0000000 --- a/backend/uvicorn.log +++ /dev/null @@ -1,48 +0,0 @@ -2023-12-16 18:57:48,126 - INFO - Starting server on port 8000 -2023-12-16 18:57:48,134 - DEBUG - Using proactor: IocpProactor -2023-12-16 18:59:27,491 - INFO - Starting server on port 8001 -2023-12-16 18:59:27,495 - DEBUG - Using proactor: IocpProactor -2023-12-16 19:01:04,537 - INFO - Starting server on port 8002 -2023-12-16 19:01:04,541 - DEBUG - Using proactor: IocpProactor -2023-12-16 19:02:17,176 - INFO - Starting server on port 8003 -2023-12-16 19:02:17,180 - DEBUG - Using proactor: IocpProactor -2023-12-16 19:07:26,670 - INFO - Starting server on port 8004 -2023-12-16 19:07:26,679 - DEBUG - Using proactor: IocpProactor -2023-12-17 01:04:06,858 - INFO - Starting server on port 8005 -2023-12-17 01:04:06,858 - DEBUG - Using proactor: IocpProactor -2023-12-17 01:05:35,106 - INFO - Starting server on port 8006 -2023-12-17 01:05:35,113 - DEBUG - Using proactor: IocpProactor -2023-12-17 01:11:51,107 - INFO - Starting server on port 8007 -2023-12-17 01:12:50,500 - INFO - Starting server on port 8007 -2023-12-17 01:12:51,000 - DEBUG - Using selector: SelectSelector -2023-12-17 01:19:14,458 - INFO - Starting server on port 8008 -2023-12-17 01:19:14,878 - DEBUG - Using selector: SelectSelector -2023-12-17 01:20:52,850 - INFO - Starting server on port 8009 -2023-12-17 01:20:53,233 - DEBUG - Using selector: SelectSelector -2023-12-17 01:27:24,874 - INFO - Starting server on port 8000 -2023-12-17 01:27:25,266 - DEBUG - Using selector: SelectSelector -2023-12-17 01:36:53,794 - INFO - Starting server on port 8001 -2023-12-17 01:36:54,202 - DEBUG - Using selector: SelectSelector -2023-12-17 01:41:31,775 - INFO - Starting server on port 8002 -2023-12-17 01:41:32,222 - DEBUG - Using selector: SelectSelector -2023-12-17 01:44:37,284 - INFO - Starting server on port 8003 -2023-12-17 01:44:37,292 - DEBUG - Using selector: SelectSelector -2023-12-17 01:45:34,851 - INFO - Starting server on port 8003 -2023-12-17 01:45:34,859 - DEBUG - Using selector: SelectSelector -2023-12-17 01:48:38,036 - INFO - Starting server on port 8003 -2023-12-17 01:48:38,043 - DEBUG - Using selector: SelectSelector -2023-12-17 01:52:06,128 - INFO - Starting server on port 0 -2023-12-17 01:52:06,134 - DEBUG - Using proactor: IocpProactor -2023-12-17 01:52:34,098 - INFO - Starting server on port 0 -2023-12-17 01:52:34,103 - DEBUG - Using proactor: IocpProactor -2023-12-17 01:53:25,391 - INFO - Starting server on port 0 -2023-12-17 01:53:25,397 - DEBUG - Using proactor: IocpProactor -2023-12-17 01:54:59,647 - INFO - Starting server on port 0 -2023-12-17 01:55:08,636 - INFO - Starting server on port 0 -2023-12-17 01:55:08,641 - DEBUG - Using proactor: IocpProactor -2023-12-17 01:55:15,396 - INFO - Starting server on port 0 -2023-12-17 01:55:15,400 - DEBUG - Using proactor: IocpProactor -2023-12-17 01:55:40,485 - INFO - Starting server on port 8003 -2023-12-17 01:55:40,490 - DEBUG - Using selector: SelectSelector -2023-12-17 01:56:45,982 - INFO - Starting server on port 8003 -2023-12-17 01:56:45,988 - DEBUG - Using selector: SelectSelector diff --git a/build.sh b/build.sh deleted file mode 100644 index 8a1067c..0000000 --- a/build.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -cd backend -source .venv/Scripts/activate -make onefolder -cp -r dist/backend* ../frontend/dist/backend - -cd .. -cd frontend -npm run build - -cd .. -rm -rf dist -mkdir dist -cp -r backend/dist/backend* dist/backend -cp -r frontend/dist/Palette*.exe dist/Palette.exe diff --git a/frontend/src/main.js b/frontend/src/main.js index d8f585c..b4c5fb7 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -83,7 +83,12 @@ function tryStartBackend(win) { let portUsed = 8000; - if (app.isPackaged) { + let packaged = app.isPackaged; + packaged = true; // Force packaged mode + + if (packaged) { + + log.info ("Running in packaged mode, using portfinder to find open port"); portfinder.getPort(async (err, port) => { if (err) { @@ -93,38 +98,53 @@ function tryStartBackend(win) { portUsed = port; // Construct the full path to the executable - let backendPath = ""; - let root = path.join(app.getAppPath(), "..", ".."); let backend_executable = "backend_0p1.exe"; if (process.platform == "darwin") backend_executable = "backend_0p1"; - backendPath = path.join(root, "backend", backend_executable); // Use double backslashes for Windows paths + let backendPath = path.join(backend_executable); // Use double backslashes for Windows paths + // If in a devmode test, use the devmode backend + if (!app.isPackaged) { + root = path.join(app.getAppPath(), ".."); + backendPath = path.join("dist", "backend_0p1", backend_executable); // Use double backslashes for Windows paths + } + // Use execFile // Define the backend executable and the arguments - const backendExecutable = backendPath; // Make sure this is just the executable name or path const args = ['--port', portUsed.toString()]; + // const command = backendPath + " " + args.join(" "); // Define the options, including the working directory const options = { cwd: path.join(root, "backend") }; + + log.info(`Backend path: ${backendPath}`) + log.info(`Backend port: ${portUsed}`) + // Use execFile to run the backend executable - backendProcess = execFile(backendExecutable, args, options, (error, stdout, stderr) => { - if (error) { - log.error(`execFile error: ${error}`); - return; - } - log.info(`stdout: ${stdout}`); - if (stderr) { - log.error(`stderr: ${stderr}`); - } + // Use spawn to run the backend executable + const backendProcess = spawn(backendPath, args, options); + + // Handle the stdout, stderr, and error events + backendProcess.stdout.on('data', (data) => { + log.info(`stdout: ${data}`); + }); + + backendProcess.stderr.on('data', (data) => { + log.error(`stderr: ${data}`); + }); + + backendProcess.on('error', (error) => { + log.error(`spawn error: ${error}`); + }); + + backendProcess.on('close', (code) => { + log.info(`child process exited with code ${code}`); }); - log.info(`Backend path: ${backendPath}`) - log.info(`Backend port: ${portUsed}`) log.info(`Backend process PID: ${backendProcess.pid}`) })