From 7cd67c3dd41f867e74b6f76753bb7464877f9bc5 Mon Sep 17 00:00:00 2001 From: SammyBits Date: Sat, 1 Nov 2025 20:14:17 -0400 Subject: [PATCH 1/9] feat: add CertHelper for TLS certificate paths and implement Spotify auth routes --- src/modules/auth/helpers/CertHelper.ts | 14 ++++++++++++++ src/modules/auth/setupSpotifyAuth.ts | 19 +++++++++---------- src/modules/{ => spotify}/routes/main.ts | 4 ++-- src/shared.ts | 14 ++++++++++++++ 4 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 src/modules/auth/helpers/CertHelper.ts rename src/modules/{ => spotify}/routes/main.ts (86%) create mode 100644 src/shared.ts diff --git a/src/modules/auth/helpers/CertHelper.ts b/src/modules/auth/helpers/CertHelper.ts new file mode 100644 index 0000000..f36937b --- /dev/null +++ b/src/modules/auth/helpers/CertHelper.ts @@ -0,0 +1,14 @@ +import { binDir } from "../../../shared"; +import { join } from "path"; + +/** + * Paths to the TLS certificate and key files. + * These are used for setting up HTTPS servers. + * + */ +const retrieveCertPaths = { + certFile: join(binDir, "certs", "cert.pem"), + keyFile: join(binDir, "certs", "key.pem"), +}; + +export { retrieveCertPaths }; diff --git a/src/modules/auth/setupSpotifyAuth.ts b/src/modules/auth/setupSpotifyAuth.ts index 2b15680..c5f2765 100644 --- a/src/modules/auth/setupSpotifyAuth.ts +++ b/src/modules/auth/setupSpotifyAuth.ts @@ -1,35 +1,34 @@ -import { spotifyAuthRoutes } from "../routes/main"; +import { spotifyAuthRoutes } from "../spotify/routes/main"; import { scopes, spotifyApi } from "../spotify/config/spotifyConfig"; import { getToken } from "../spotify/config/spotifyDB"; -import path from "path"; - -const root = process.cwd(); -const certPath = path.join(root, "certs/cert.pem"); -const keyPath = path.join(root, "certs/key.pem"); +import { retrieveCertPaths } from "./helpers/CertHelper"; export async function setupSpotifyAuth() { const existing = getToken(); if (existing) { console.log("[Spotify] Token already exists. No need to reauthorize."); - console.log("[Spotify] 👉 Delete spotify.db to reauthorize."); + console.log("[Spotify] Delete spotify.db to reauthorize."); // No need to close the app, just return return; } // Generate authorization URL const authorizeURL = spotifyApi.createAuthorizeURL(scopes, "state-spotify"); - console.log("\n🔗 Open this link to authorize Spotify:"); + console.log("\n Open this link to authorize Spotify:"); console.log(authorizeURL); // Start Hono just to handle callback Bun.serve({ port: Number(Bun.env.HONO_PORT) || 54321, fetch: spotifyAuthRoutes.fetch, - tls: { certFile: certPath, keyFile: keyPath }, + tls: { + certFile: retrieveCertPaths.certFile, + keyFile: retrieveCertPaths.keyFile, + }, }); console.log( - `🌐 Server HTTPS listening on https://localhost:${Bun.env.HONO_PORT}` + `Server HTTPS listening on https://localhost:${Bun.env.HONO_PORT}` ); } diff --git a/src/modules/routes/main.ts b/src/modules/spotify/routes/main.ts similarity index 86% rename from src/modules/routes/main.ts rename to src/modules/spotify/routes/main.ts index b1f36ab..5c16319 100644 --- a/src/modules/routes/main.ts +++ b/src/modules/spotify/routes/main.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; -import { spotifyApi } from "../spotify/config/spotifyConfig"; -import { saveToken } from "../spotify/config/spotifyDB"; +import { spotifyApi } from "../config/spotifyConfig"; +import { saveToken } from "../config/spotifyDB"; export const spotifyAuthRoutes = new Hono(); diff --git a/src/shared.ts b/src/shared.ts new file mode 100644 index 0000000..3c6e309 --- /dev/null +++ b/src/shared.ts @@ -0,0 +1,14 @@ +import { dirname } from "path"; +export const isProduction = Bun.env.NODE_ENV === "production"; +/** + * The directory where the main Bun script is located. + * This is useful for resolving paths relative to the executable. + * @example + * ```ts + * import { binDir } from "./shared"; + * import path from "path"; + * + * const configPath = path.join(binDir, "config", "settings.json"); + * ``` + */ +export const binDir = dirname(Bun.main); From 69966b693f54ba8f5021ccb4c2e8be4697909735 Mon Sep 17 00:00:00 2001 From: SammyBits Date: Sat, 1 Nov 2025 20:22:24 -0400 Subject: [PATCH 2/9] feat: implement Spotify authentication with token management and HTTPS server setup --- .../CertHelper.ts => auth/certHelper.ts} | 2 +- src/index.ts | 6 ++-- src/listeners/hotkeys.ts | 2 +- src/{modules => }/spotify/commands/main.ts | 0 src/{modules => }/spotify/commands/player.ts | 0 .../spotify/config/spotifyConfig.ts | 0 src/{modules => }/spotify/config/spotifyDB.ts | 0 src/{modules => }/spotify/routes/main.ts | 0 .../startup.ts} | 32 ++++++++++++++----- 9 files changed, 29 insertions(+), 13 deletions(-) rename src/{modules/auth/helpers/CertHelper.ts => auth/certHelper.ts} (87%) rename src/{modules => }/spotify/commands/main.ts (100%) rename src/{modules => }/spotify/commands/player.ts (100%) rename src/{modules => }/spotify/config/spotifyConfig.ts (100%) rename src/{modules => }/spotify/config/spotifyDB.ts (100%) rename src/{modules => }/spotify/routes/main.ts (100%) rename src/{modules/auth/setupSpotifyAuth.ts => spotify/startup.ts} (50%) diff --git a/src/modules/auth/helpers/CertHelper.ts b/src/auth/certHelper.ts similarity index 87% rename from src/modules/auth/helpers/CertHelper.ts rename to src/auth/certHelper.ts index f36937b..f13ec02 100644 --- a/src/modules/auth/helpers/CertHelper.ts +++ b/src/auth/certHelper.ts @@ -1,4 +1,4 @@ -import { binDir } from "../../../shared"; +import { binDir } from "../shared"; import { join } from "path"; /** diff --git a/src/index.ts b/src/index.ts index 86c3a4f..e5cc7f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { startListener } from "./listeners/hotkeys"; -import { setupSpotifyAuth } from "./modules/auth/setupSpotifyAuth"; -import { spotifyCommands } from "./modules/spotify/commands/main"; +import { initSpotifyAuth } from "./spotify/startup"; +import { spotifyCommands } from "./spotify/commands/main"; const allCommands = [...spotifyCommands]; @@ -8,4 +8,4 @@ console.log("Hotkeys listener init..."); startListener(allCommands); console.log("Hotkeys listener started."); -setupSpotifyAuth(); +initSpotifyAuth(); diff --git a/src/listeners/hotkeys.ts b/src/listeners/hotkeys.ts index df28a79..d933626 100644 --- a/src/listeners/hotkeys.ts +++ b/src/listeners/hotkeys.ts @@ -2,7 +2,7 @@ import { GlobalKeyboardListener, IGlobalKeyEvent, } from "node-global-key-listener"; -import { Command } from "../modules/spotify/commands/main"; +import { Command } from "../spotify/commands/main"; function normalizeModifier(key: string) { if (!key) return ""; diff --git a/src/modules/spotify/commands/main.ts b/src/spotify/commands/main.ts similarity index 100% rename from src/modules/spotify/commands/main.ts rename to src/spotify/commands/main.ts diff --git a/src/modules/spotify/commands/player.ts b/src/spotify/commands/player.ts similarity index 100% rename from src/modules/spotify/commands/player.ts rename to src/spotify/commands/player.ts diff --git a/src/modules/spotify/config/spotifyConfig.ts b/src/spotify/config/spotifyConfig.ts similarity index 100% rename from src/modules/spotify/config/spotifyConfig.ts rename to src/spotify/config/spotifyConfig.ts diff --git a/src/modules/spotify/config/spotifyDB.ts b/src/spotify/config/spotifyDB.ts similarity index 100% rename from src/modules/spotify/config/spotifyDB.ts rename to src/spotify/config/spotifyDB.ts diff --git a/src/modules/spotify/routes/main.ts b/src/spotify/routes/main.ts similarity index 100% rename from src/modules/spotify/routes/main.ts rename to src/spotify/routes/main.ts diff --git a/src/modules/auth/setupSpotifyAuth.ts b/src/spotify/startup.ts similarity index 50% rename from src/modules/auth/setupSpotifyAuth.ts rename to src/spotify/startup.ts index c5f2765..e92bd1d 100644 --- a/src/modules/auth/setupSpotifyAuth.ts +++ b/src/spotify/startup.ts @@ -1,9 +1,22 @@ -import { spotifyAuthRoutes } from "../spotify/routes/main"; -import { scopes, spotifyApi } from "../spotify/config/spotifyConfig"; -import { getToken } from "../spotify/config/spotifyDB"; -import { retrieveCertPaths } from "./helpers/CertHelper"; +import { spotifyAuthRoutes } from "./routes/main"; +import { scopes, spotifyApi } from "./config/spotifyConfig"; +import { getToken } from "./config/spotifyDB"; +import { retrieveCertPaths } from "../auth/certHelper"; +/** + * Initializes Spotify authentication by retrieving existing tokens + * and setting up the HTTPS server for OAuth callbacks. + * + */ +export const initSpotifyAuth = () => { + retrieveAuthorizationToken(); + serveSpotify(); +}; -export async function setupSpotifyAuth() { +/** + * Sets up Spotify authentication by checking for existing tokens + * and generating an authorization URL if none exist. + */ +const retrieveAuthorizationToken = () => { const existing = getToken(); if (existing) { @@ -17,8 +30,11 @@ export async function setupSpotifyAuth() { const authorizeURL = spotifyApi.createAuthorizeURL(scopes, "state-spotify"); console.log("\n Open this link to authorize Spotify:"); console.log(authorizeURL); - - // Start Hono just to handle callback +}; +/** + * Sets up and starts the HTTPS server to handle Spotify OAuth callbacks. + */ +const serveSpotify = () => { Bun.serve({ port: Number(Bun.env.HONO_PORT) || 54321, fetch: spotifyAuthRoutes.fetch, @@ -31,4 +47,4 @@ export async function setupSpotifyAuth() { console.log( `Server HTTPS listening on https://localhost:${Bun.env.HONO_PORT}` ); -} +}; From 866040fc278c1aeb60880abcb16e3a6723c07501 Mon Sep 17 00:00:00 2001 From: SammyBits Date: Sat, 1 Nov 2025 20:43:34 -0400 Subject: [PATCH 3/9] feat: implement token management with database integration and update .gitignore --- .gitignore | 4 +-- src/spotify/config/spotifyDB.ts | 11 +++---- src/spotify/database/tokensServices.ts | 33 +++++++++++++++++++ .../models/tokens/retrieveTokensResponse.ts | 17 ++++++++++ .../models/tokens/saveTokensRequest.ts | 21 ++++++++++++ src/spotify/startup.ts | 10 +++--- 6 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 src/spotify/database/tokensServices.ts create mode 100644 src/spotify/models/tokens/retrieveTokensResponse.ts create mode 100644 src/spotify/models/tokens/saveTokensRequest.ts diff --git a/.gitignore b/.gitignore index a775dfb..daefe6c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store src/spotify.db -certs/cert.pem -certs/key.pem +src/certs/* spotify.db +src/keyspotic.db diff --git a/src/spotify/config/spotifyDB.ts b/src/spotify/config/spotifyDB.ts index 7344b02..046cae4 100644 --- a/src/spotify/config/spotifyDB.ts +++ b/src/spotify/config/spotifyDB.ts @@ -1,14 +1,11 @@ import fs from "fs"; -import path from "path"; +import { join } from "path"; import { Database } from "bun:sqlite"; import { fileURLToPath } from "url"; +import { binDir } from "../../shared"; -const root = process.cwd(); - -if (!fs.existsSync(root)) fs.mkdirSync(root, { recursive: true }); - -const dbPath = path.join(root, "spotify.db"); -const db = new Database(dbPath); +const dbPath = join(binDir, "keyspotic.db"); +export const db = new Database(dbPath); db.run(` CREATE TABLE IF NOT EXISTS spotify_tokens ( diff --git a/src/spotify/database/tokensServices.ts b/src/spotify/database/tokensServices.ts new file mode 100644 index 0000000..a01beda --- /dev/null +++ b/src/spotify/database/tokensServices.ts @@ -0,0 +1,33 @@ +import db from "../config/spotifyDB"; +import { + mapRowToTokensResponse, + RetrieveTokensResponse as TokensResponse, +} from "../models/tokens/retrieveTokensResponse"; +import { mapToDb, SaveTokensRequest } from "../models/tokens/saveTokensRequest"; + +/** + * Retrieves the most recent token information from the database. + * @returns The most recent TokensResponse object or null if not found. + */ +export const retrieveTokens = (): TokensResponse | null => { + const query = "SELECT * FROM spotify_tokens ORDER BY id DESC LIMIT 1"; + const row = db.query(query).get(); + return mapRowToTokensResponse(row) || null; +}; + +/** + * Saves new token information to the database. + * @param tokens - The token information to save. + */ +export const saveTokens = (tokens: SaveTokensRequest): void => { + const insertQuery = `INSERT INTO spotify_tokens + ( + access_token, + refresh_token, + expires_at + ) + VALUES (?, ?, ?)`; + + const params = Object.values(mapToDb(tokens)); + db.run(insertQuery, params); +}; diff --git a/src/spotify/models/tokens/retrieveTokensResponse.ts b/src/spotify/models/tokens/retrieveTokensResponse.ts new file mode 100644 index 0000000..5a0d94a --- /dev/null +++ b/src/spotify/models/tokens/retrieveTokensResponse.ts @@ -0,0 +1,17 @@ +export type RetrieveTokensResponse = { + accessToken: string; + refreshToken: string; + expiresIn: number; +}; +/** + * Maps a database row to a TokensResponse object. + * @param row - The database row containing token information. + * @returns The mapped TokensResponse object. + */ +export const mapRowToTokensResponse = (row: any): RetrieveTokensResponse => { + return { + accessToken: row.access_token, + refreshToken: row.refresh_token, + expiresIn: row.expires_at, + }; +}; diff --git a/src/spotify/models/tokens/saveTokensRequest.ts b/src/spotify/models/tokens/saveTokensRequest.ts new file mode 100644 index 0000000..0e5cff0 --- /dev/null +++ b/src/spotify/models/tokens/saveTokensRequest.ts @@ -0,0 +1,21 @@ +export type SaveTokensRequest = { + accessToken: string; + refreshToken: string; + expiresIn: number; +}; + +export const mapToSaveTokensRequest = (data: any): SaveTokensRequest => { + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + }; +}; + +export const mapToDb = (data: SaveTokensRequest) => { + return { + access_token: data.accessToken, + refresh_token: data.refreshToken, + expires_at: data.expiresIn, + }; +}; diff --git a/src/spotify/startup.ts b/src/spotify/startup.ts index e92bd1d..d1cd36f 100644 --- a/src/spotify/startup.ts +++ b/src/spotify/startup.ts @@ -8,7 +8,8 @@ import { retrieveCertPaths } from "../auth/certHelper"; * */ export const initSpotifyAuth = () => { - retrieveAuthorizationToken(); + const result = retrieveAuthorizationToken(); + if (result) return; serveSpotify(); }; @@ -16,21 +17,22 @@ export const initSpotifyAuth = () => { * Sets up Spotify authentication by checking for existing tokens * and generating an authorization URL if none exist. */ -const retrieveAuthorizationToken = () => { +const retrieveAuthorizationToken = (): boolean => { const existing = getToken(); if (existing) { console.log("[Spotify] Token already exists. No need to reauthorize."); console.log("[Spotify] Delete spotify.db to reauthorize."); - // No need to close the app, just return - return; + return true; } // Generate authorization URL const authorizeURL = spotifyApi.createAuthorizeURL(scopes, "state-spotify"); console.log("\n Open this link to authorize Spotify:"); console.log(authorizeURL); + return false; }; + /** * Sets up and starts the HTTPS server to handle Spotify OAuth callbacks. */ From 597dbdf06f0748413bc5d71e363649424c2ed0f0 Mon Sep 17 00:00:00 2001 From: SammyBits Date: Sat, 1 Nov 2025 20:56:49 -0400 Subject: [PATCH 4/9] feat: create spotify_tokens table and update token management functions --- .../001_create_spotify_tokens_table.sql | 6 ++ src/spotify/config/spotifyDB.ts | 61 ++++--------------- src/spotify/database/tokensServices.ts | 29 +++++++++ src/spotify/routes/main.ts | 4 +- src/spotify/startup.ts | 6 +- 5 files changed, 53 insertions(+), 53 deletions(-) create mode 100644 src/migrations/001_create_spotify_tokens_table.sql diff --git a/src/migrations/001_create_spotify_tokens_table.sql b/src/migrations/001_create_spotify_tokens_table.sql new file mode 100644 index 0000000..37e482f --- /dev/null +++ b/src/migrations/001_create_spotify_tokens_table.sql @@ -0,0 +1,6 @@ + CREATE TABLE IF NOT EXISTS spotify_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + access_token TEXT, + refresh_token TEXT, + expires_at INTEGER + ); \ No newline at end of file diff --git a/src/spotify/config/spotifyDB.ts b/src/spotify/config/spotifyDB.ts index 046cae4..c6dd62d 100644 --- a/src/spotify/config/spotifyDB.ts +++ b/src/spotify/config/spotifyDB.ts @@ -5,55 +5,20 @@ import { fileURLToPath } from "url"; import { binDir } from "../../shared"; const dbPath = join(binDir, "keyspotic.db"); -export const db = new Database(dbPath); +const db = new Database(dbPath); -db.run(` - CREATE TABLE IF NOT EXISTS spotify_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - access_token TEXT, - refresh_token TEXT, - expires_at INTEGER - ) -`); +const queries = { + createTokensTable: fs.readFileSync( + fileURLToPath( + new URL( + "./migrations/001_create_spotify_tokens_table.sql", + import.meta.url + ) + ), + "utf-8" + ), +}; -export function getToken(): { - access_token: string; - refresh_token: string; - expires_at: number; -} { - const row = db - .query("SELECT * FROM spotify_tokens ORDER BY id DESC LIMIT 1") - .get(); - return ( - (row as { - access_token: string; - refresh_token: string; - expires_at: number; - }) || null - ); -} +db.run(queries.createTokensTable); -export function saveToken({ - access_token, - refresh_token, - expires_in, -}: { - access_token: string; - refresh_token: string; - expires_in: number; -}) { - const expires_at = Date.now() + expires_in * 1000; // Convert to milliseconds - db.run( - "INSERT INTO spotify_tokens (access_token, refresh_token, expires_at) VALUES (?, ?, ?)", - [access_token, refresh_token, expires_at] - ); -} - -export function updateAccessToken(newAccessToken: string, expiresIn: number) { - const expires_at = Date.now() + expiresIn * 1000; - db.run( - "UPDATE spotify_tokens SET access_token = ?, expires_at = ? WHERE id = (SELECT id FROM spotify_tokens ORDER BY id DESC LIMIT 1)", - [newAccessToken, expires_at] - ); -} export default db; diff --git a/src/spotify/database/tokensServices.ts b/src/spotify/database/tokensServices.ts index a01beda..ef52014 100644 --- a/src/spotify/database/tokensServices.ts +++ b/src/spotify/database/tokensServices.ts @@ -14,6 +14,15 @@ export const retrieveTokens = (): TokensResponse | null => { const row = db.query(query).get(); return mapRowToTokensResponse(row) || null; }; +/** + * Checks if any token records exist in the database. + * @returns True if token records exist, false otherwise. + */ +export const tokenExists = (): boolean => { + const query = "SELECT COUNT(*) as count FROM spotify_tokens"; + const row = db.query(query).get() as { count: number }; + return row.count > 0; +}; /** * Saves new token information to the database. @@ -31,3 +40,23 @@ export const saveTokens = (tokens: SaveTokensRequest): void => { const params = Object.values(mapToDb(tokens)); db.run(insertQuery, params); }; +/** + * Updates the access token and its expiration time in the database. + * @param newAccessToken - The new access token. + * @param expiresIn - The expiration time in seconds. + */ +export const updateAccessToken = ( + newAccessToken: string, + expiresIn: number +): void => { + const expiresAtMs = Date.now() + convertToMilliseconds(expiresIn); + const updateQuery = `UPDATE spotify_tokens + SET access_token = ?, expires_at = ? + WHERE id = (SELECT id FROM spotify_tokens ORDER BY id DESC LIMIT 1)`; + + db.run(updateQuery, [newAccessToken, expiresAtMs]); +}; + +const convertToMilliseconds = (seconds: number): number => { + return seconds * 1000; +}; diff --git a/src/spotify/routes/main.ts b/src/spotify/routes/main.ts index 5c16319..9dcab29 100644 --- a/src/spotify/routes/main.ts +++ b/src/spotify/routes/main.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { spotifyApi } from "../config/spotifyConfig"; -import { saveToken } from "../config/spotifyDB"; +import { saveTokens } from "../database/tokensServices"; export const spotifyAuthRoutes = new Hono(); @@ -14,7 +14,7 @@ spotifyAuthRoutes.get("/api/v1/spotify/callback", async (c) => { try { const data = await spotifyApi.authorizationCodeGrant(code); const { access_token, refresh_token, expires_in } = data.body; - saveToken({ access_token, refresh_token, expires_in }); + saveTokens({ access_token, refresh_token, expires_in }); console.log("Token saved successfully."); return c.text("Spotify authorized successfully. You can close this tab."); } catch (err) { diff --git a/src/spotify/startup.ts b/src/spotify/startup.ts index d1cd36f..835891e 100644 --- a/src/spotify/startup.ts +++ b/src/spotify/startup.ts @@ -1,7 +1,7 @@ import { spotifyAuthRoutes } from "./routes/main"; import { scopes, spotifyApi } from "./config/spotifyConfig"; -import { getToken } from "./config/spotifyDB"; import { retrieveCertPaths } from "../auth/certHelper"; +import { tokenExists } from "./database/tokensServices"; /** * Initializes Spotify authentication by retrieving existing tokens * and setting up the HTTPS server for OAuth callbacks. @@ -18,9 +18,9 @@ export const initSpotifyAuth = () => { * and generating an authorization URL if none exist. */ const retrieveAuthorizationToken = (): boolean => { - const existing = getToken(); + const isTokenExists = tokenExists(); - if (existing) { + if (isTokenExists) { console.log("[Spotify] Token already exists. No need to reauthorize."); console.log("[Spotify] Delete spotify.db to reauthorize."); return true; From 87ca78d5d811d09b6320ec8a6d57be2204c4824c Mon Sep 17 00:00:00 2001 From: SammyBits Date: Sat, 1 Nov 2025 21:12:38 -0400 Subject: [PATCH 5/9] feat: refactor token management and add result handling for Spotify authentication --- src/auth/certHelper.ts | 2 +- src/shared/result.ts | 22 +++++++++++++ src/{ => shared}/shared.ts | 0 src/spotify/config/spotifyDB.ts | 2 +- src/spotify/routes/main.ts | 18 +++++------ .../{ => services}/database/tokensServices.ts | 9 ++++-- src/spotify/startup.ts | 2 +- src/spotify/useCases/saveTokensUseCases.ts | 31 +++++++++++++++++++ 8 files changed, 69 insertions(+), 17 deletions(-) create mode 100644 src/shared/result.ts rename src/{ => shared}/shared.ts (100%) rename src/spotify/{ => services}/database/tokensServices.ts (90%) create mode 100644 src/spotify/useCases/saveTokensUseCases.ts diff --git a/src/auth/certHelper.ts b/src/auth/certHelper.ts index f13ec02..0b526ad 100644 --- a/src/auth/certHelper.ts +++ b/src/auth/certHelper.ts @@ -1,4 +1,4 @@ -import { binDir } from "../shared"; +import { binDir } from "../shared/shared"; import { join } from "path"; /** diff --git a/src/shared/result.ts b/src/shared/result.ts new file mode 100644 index 0000000..aed029c --- /dev/null +++ b/src/shared/result.ts @@ -0,0 +1,22 @@ +export type Result = Ok | Err; + +export class Ok { + readonly isOk = true; + readonly isErr = false; + constructor(public readonly value: T) {} +} + +export class Err { + readonly isOk = false; + readonly isErr = true; + constructor(public readonly error: E) {} +} + +// helpers +export const ok = (value: T): Result => new Ok(value); +export const err = (error: E): Result => new Err(error); + +export const isOk = (result: Result): result is Ok => + result.isOk; +export const isErr = (result: Result): result is Err => + result.isErr; diff --git a/src/shared.ts b/src/shared/shared.ts similarity index 100% rename from src/shared.ts rename to src/shared/shared.ts diff --git a/src/spotify/config/spotifyDB.ts b/src/spotify/config/spotifyDB.ts index c6dd62d..0689803 100644 --- a/src/spotify/config/spotifyDB.ts +++ b/src/spotify/config/spotifyDB.ts @@ -2,7 +2,7 @@ import fs from "fs"; import { join } from "path"; import { Database } from "bun:sqlite"; import { fileURLToPath } from "url"; -import { binDir } from "../../shared"; +import { binDir } from "../../shared/shared"; const dbPath = join(binDir, "keyspotic.db"); const db = new Database(dbPath); diff --git a/src/spotify/routes/main.ts b/src/spotify/routes/main.ts index 9dcab29..7743020 100644 --- a/src/spotify/routes/main.ts +++ b/src/spotify/routes/main.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import { spotifyApi } from "../config/spotifyConfig"; -import { saveTokens } from "../database/tokensServices"; +import { saveTokens } from "../services/database/tokensServices"; +import { executeAsync } from "../useCases/saveTokensUseCases"; export const spotifyAuthRoutes = new Hono(); @@ -9,16 +10,11 @@ spotifyAuthRoutes.get("/api/v1/spotify/callback", async (c) => { const error = c.req.query("error"); if (error) return c.text(`Error: ${error}`, 400); - if (!code) return c.text("No se recibió código de Spotify.", 400); - try { - const data = await spotifyApi.authorizationCodeGrant(code); - const { access_token, refresh_token, expires_in } = data.body; - saveTokens({ access_token, refresh_token, expires_in }); - console.log("Token saved successfully."); - return c.text("Spotify authorized successfully. You can close this tab."); - } catch (err) { - console.error("Error saving token:", err); - return c.text("Error saving token.", 500); + const result = await executeAsync(code); + if (result.isErr) { + return c.text(`Error: ${result.error}`, 400); } + + return c.text("Spotify authorized successfully. You can close this tab."); }); diff --git a/src/spotify/database/tokensServices.ts b/src/spotify/services/database/tokensServices.ts similarity index 90% rename from src/spotify/database/tokensServices.ts rename to src/spotify/services/database/tokensServices.ts index ef52014..e4d859e 100644 --- a/src/spotify/database/tokensServices.ts +++ b/src/spotify/services/database/tokensServices.ts @@ -1,9 +1,12 @@ -import db from "../config/spotifyDB"; +import db from "../../config/spotifyDB"; import { mapRowToTokensResponse, RetrieveTokensResponse as TokensResponse, -} from "../models/tokens/retrieveTokensResponse"; -import { mapToDb, SaveTokensRequest } from "../models/tokens/saveTokensRequest"; +} from "../../models/tokens/retrieveTokensResponse"; +import { + mapToDb, + SaveTokensRequest, +} from "../../models/tokens/saveTokensRequest"; /** * Retrieves the most recent token information from the database. diff --git a/src/spotify/startup.ts b/src/spotify/startup.ts index 835891e..976f3e2 100644 --- a/src/spotify/startup.ts +++ b/src/spotify/startup.ts @@ -1,7 +1,7 @@ import { spotifyAuthRoutes } from "./routes/main"; import { scopes, spotifyApi } from "./config/spotifyConfig"; import { retrieveCertPaths } from "../auth/certHelper"; -import { tokenExists } from "./database/tokensServices"; +import { tokenExists } from "./services/database/tokensServices"; /** * Initializes Spotify authentication by retrieving existing tokens * and setting up the HTTPS server for OAuth callbacks. diff --git a/src/spotify/useCases/saveTokensUseCases.ts b/src/spotify/useCases/saveTokensUseCases.ts new file mode 100644 index 0000000..0a91a20 --- /dev/null +++ b/src/spotify/useCases/saveTokensUseCases.ts @@ -0,0 +1,31 @@ +import { err, ok, Result } from "../../shared/result"; +import { spotifyApi } from "../config/spotifyConfig"; +import { SaveTokensRequest } from "../models/tokens/saveTokensRequest"; +import { saveTokens } from "../services/database/tokensServices"; + +/** + * Executes the process of saving Spotify tokens using the provided authorization code. + */ +type SaveError = "CodeMissing" | "SaveFailed"; + +export const executeAsync = async ( + code: string | undefined +): Promise> => { + try { + if (!code) return err("CodeMissing"); + + const data = await spotifyApi.authorizationCodeGrant(code); + + const saveData: SaveTokensRequest = { + accessToken: data.body.access_token, + refreshToken: data.body.refresh_token, + expiresIn: data.body.expires_in, + }; + + saveTokens(saveData); + return ok(true); + } catch (error) { + console.error("Error saving tokens:", error); + return err("SaveFailed"); + } +}; From 5e00124273c43178e4a07fc0c2a5d935a38260a9 Mon Sep 17 00:00:00 2001 From: SammyBits Date: Sat, 1 Nov 2025 21:19:18 -0400 Subject: [PATCH 6/9] feat: refactor Spotify API initialization and token management logic --- src/spotify/config/spotifyConfig.ts | 48 +++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/src/spotify/config/spotifyConfig.ts b/src/spotify/config/spotifyConfig.ts index ae3fa2c..d7df417 100644 --- a/src/spotify/config/spotifyConfig.ts +++ b/src/spotify/config/spotifyConfig.ts @@ -1,5 +1,8 @@ import SpotifyWebApi from "spotify-web-api-node"; -import { getToken, updateAccessToken } from "./spotifyDB"; +import { + retrieveTokens, + updateAccessToken, +} from "../services/database/tokensServices"; export const spotifyApi = new SpotifyWebApi({ clientId: Bun.env.SPOTIFY_CLIENT_ID!, @@ -13,25 +16,45 @@ export const scopes = [ "user-read-currently-playing", "playlist-read-private", ]; +/** + * Initializes the Spotify API client by retrieving stored tokens + * and refreshing them if necessary. + * @returns {Promise} The initialized Spotify API client or null if authorization is required. + */ +export const initSpotify = async (): Promise => { + const data = retrieveTokens(); -export async function initSpotify() { - const tokenData = getToken(); - - if (!tokenData) { + if (!data) { console.warn("[Spotify] No token found. Please authorize first."); return null; } - const { access_token, refresh_token, expires_at } = tokenData; + const { accessToken, refreshToken, expiresIn } = data; - if (Date.now() < expires_at) { - spotifyApi.setAccessToken(access_token); + if (isNotExpired(expiresIn)) { + spotifyApi.setAccessToken(accessToken); return spotifyApi; } + await refreshTokenIfNeeded(refreshToken, expiresIn); + + return spotifyApi; +}; +/** + * Refreshes the Spotify access token if it has expired. + * @param refreshToken The refresh token to use for refreshing the access token. + * @param expiresIn The expiration time of the current access token. + * @returns void + */ +const refreshTokenIfNeeded = async ( + refreshToken: string, + expiresIn: number +) => { + if (isNotExpired(expiresIn)) return; + console.log("[Spotify] Token expired, refreshing..."); - spotifyApi.setRefreshToken(refresh_token); + spotifyApi.setRefreshToken(refreshToken); try { const data = await spotifyApi.refreshAccessToken(); const newAccessToken = data.body.access_token; @@ -42,8 +65,9 @@ export async function initSpotify() { console.log("[Spotify] Token refreshed successfully."); } catch (err) { console.error("[Spotify] Error refreshing token:", err); - return null; } +}; - return spotifyApi; -} +const isNotExpired = (expiresIn: number) => { + return Date.now() < expiresIn; +}; From ccb6dfce9fcd6d25089bcb3e3cc786123d801803 Mon Sep 17 00:00:00 2001 From: SammyBits Date: Sat, 1 Nov 2025 21:34:33 -0400 Subject: [PATCH 7/9] feat: refactor Spotify configuration and database integration --- src/spotify/commands/player.ts | 2 +- .../config/{spotifyDB.ts => database.ts} | 17 ++++++----------- .../config/{spotifyConfig.ts => spotify.ts} | 0 src/spotify/routes/main.ts | 3 +-- src/spotify/services/database/tokensServices.ts | 2 +- src/spotify/startup.ts | 2 +- src/spotify/useCases/saveTokensUseCases.ts | 2 +- 7 files changed, 11 insertions(+), 17 deletions(-) rename src/spotify/config/{spotifyDB.ts => database.ts} (56%) rename src/spotify/config/{spotifyConfig.ts => spotify.ts} (100%) diff --git a/src/spotify/commands/player.ts b/src/spotify/commands/player.ts index c1d23f5..46361c8 100644 --- a/src/spotify/commands/player.ts +++ b/src/spotify/commands/player.ts @@ -1,4 +1,4 @@ -import { initSpotify } from "../config/spotifyConfig"; +import { initSpotify } from "../config/spotify"; export async function playPause() { const spotifyApi = await initSpotify(); diff --git a/src/spotify/config/spotifyDB.ts b/src/spotify/config/database.ts similarity index 56% rename from src/spotify/config/spotifyDB.ts rename to src/spotify/config/database.ts index 0689803..9da8640 100644 --- a/src/spotify/config/spotifyDB.ts +++ b/src/spotify/config/database.ts @@ -1,22 +1,17 @@ import fs from "fs"; import { join } from "path"; import { Database } from "bun:sqlite"; -import { fileURLToPath } from "url"; import { binDir } from "../../shared/shared"; const dbPath = join(binDir, "keyspotic.db"); const db = new Database(dbPath); - +const migrationPath = join( + binDir, + "migrations", + "001_create_spotify_tokens_table.sql" +); const queries = { - createTokensTable: fs.readFileSync( - fileURLToPath( - new URL( - "./migrations/001_create_spotify_tokens_table.sql", - import.meta.url - ) - ), - "utf-8" - ), + createTokensTable: fs.readFileSync(migrationPath, "utf-8"), }; db.run(queries.createTokensTable); diff --git a/src/spotify/config/spotifyConfig.ts b/src/spotify/config/spotify.ts similarity index 100% rename from src/spotify/config/spotifyConfig.ts rename to src/spotify/config/spotify.ts diff --git a/src/spotify/routes/main.ts b/src/spotify/routes/main.ts index 7743020..5a4a4d8 100644 --- a/src/spotify/routes/main.ts +++ b/src/spotify/routes/main.ts @@ -1,6 +1,5 @@ import { Hono } from "hono"; -import { spotifyApi } from "../config/spotifyConfig"; -import { saveTokens } from "../services/database/tokensServices"; + import { executeAsync } from "../useCases/saveTokensUseCases"; export const spotifyAuthRoutes = new Hono(); diff --git a/src/spotify/services/database/tokensServices.ts b/src/spotify/services/database/tokensServices.ts index e4d859e..8034f21 100644 --- a/src/spotify/services/database/tokensServices.ts +++ b/src/spotify/services/database/tokensServices.ts @@ -1,4 +1,4 @@ -import db from "../../config/spotifyDB"; +import db from "../../config/database"; import { mapRowToTokensResponse, RetrieveTokensResponse as TokensResponse, diff --git a/src/spotify/startup.ts b/src/spotify/startup.ts index 976f3e2..19aa290 100644 --- a/src/spotify/startup.ts +++ b/src/spotify/startup.ts @@ -1,5 +1,5 @@ import { spotifyAuthRoutes } from "./routes/main"; -import { scopes, spotifyApi } from "./config/spotifyConfig"; +import { scopes, spotifyApi } from "./config/spotify"; import { retrieveCertPaths } from "../auth/certHelper"; import { tokenExists } from "./services/database/tokensServices"; /** diff --git a/src/spotify/useCases/saveTokensUseCases.ts b/src/spotify/useCases/saveTokensUseCases.ts index 0a91a20..4135e73 100644 --- a/src/spotify/useCases/saveTokensUseCases.ts +++ b/src/spotify/useCases/saveTokensUseCases.ts @@ -1,5 +1,5 @@ import { err, ok, Result } from "../../shared/result"; -import { spotifyApi } from "../config/spotifyConfig"; +import { spotifyApi } from "../config/spotify"; import { SaveTokensRequest } from "../models/tokens/saveTokensRequest"; import { saveTokens } from "../services/database/tokensServices"; From defbeed80c22562682407eb4452517f453f662f7 Mon Sep 17 00:00:00 2001 From: SammyBits Date: Sat, 1 Nov 2025 21:53:35 -0400 Subject: [PATCH 8/9] feat: enhance Spotify integration with improved token management and command handling --- .gitignore | 2 + src/shared/shared.ts | 2 +- src/spotify/commands/main.ts | 21 +++++---- src/spotify/commands/player.ts | 45 ++++++++++++------- src/spotify/config/database.ts | 18 ++++---- .../models/tokens/retrieveTokensResponse.ts | 7 +++ .../models/tokens/saveTokensRequest.ts | 19 +++++++- src/spotify/startup.ts | 2 +- 8 files changed, 80 insertions(+), 36 deletions(-) diff --git a/.gitignore b/.gitignore index daefe6c..3259d7a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,5 +34,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .DS_Store src/spotify.db src/certs/* +certs/* spotify.db src/keyspotic.db +keyspotic.db diff --git a/src/shared/shared.ts b/src/shared/shared.ts index 3c6e309..8934283 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -11,4 +11,4 @@ export const isProduction = Bun.env.NODE_ENV === "production"; * const configPath = path.join(binDir, "config", "settings.json"); * ``` */ -export const binDir = dirname(Bun.main); +export const binDir = isProduction ? dirname(Bun.main) : process.cwd(); diff --git a/src/spotify/commands/main.ts b/src/spotify/commands/main.ts index c9f32f3..b70dc24 100644 --- a/src/spotify/commands/main.ts +++ b/src/spotify/commands/main.ts @@ -1,18 +1,21 @@ -import { playPause, nextTrack, previousTrack } from "./player"; -import path from "path"; +import { binDir } from "../../shared/shared"; +import { playOrPause, nextTrack, previousTrack } from "./player"; +import { join } from "path"; -export interface Command { +export type Command = { hotkey: string; action: () => void; -} -const root = process.cwd(); -const hotkeysPath = Bun.file(path.join(root, "hotkeys.json")); -const hotkeysText = await hotkeysPath.text(); +}; +const hotkeysText = await Bun.file(join(binDir, "hotkeys.json")).text(); const hotkeysConfig = JSON.parse(hotkeysText); -//TODO: Use ZOD or similar for validation +/** + * Array of Spotify commands with their associated hotkeys and actions. + * Each command consists of a hotkey string and a corresponding action function. + * This array is used to map user inputs to Spotify playback controls. + */ export const spotifyCommands: Command[] = [ - { hotkey: hotkeysConfig.spotify.playPause, action: playPause }, + { hotkey: hotkeysConfig.spotify.playOrPause, action: playOrPause }, { hotkey: hotkeysConfig.spotify.nextTrack, action: nextTrack }, { hotkey: hotkeysConfig.spotify.previousTrack, action: previousTrack }, ]; diff --git a/src/spotify/commands/player.ts b/src/spotify/commands/player.ts index 46361c8..8b9d961 100644 --- a/src/spotify/commands/player.ts +++ b/src/spotify/commands/player.ts @@ -1,8 +1,10 @@ -import { initSpotify } from "../config/spotify"; - -export async function playPause() { - const spotifyApi = await initSpotify(); - if (!spotifyApi) return console.warn("[Spotify] Not initialized."); +import { spotifyApi } from "../config/spotify"; +/** + * Toggles playback state: plays if paused, pauses if playing. + * @returns void + */ +export const playOrPause = async () => { + if (!isSpotifyInitialized()) return; const playback = await spotifyApi.getMyCurrentPlaybackState(); if (playback.body?.is_playing) { @@ -12,18 +14,31 @@ export async function playPause() { await spotifyApi.play(); console.log("[Spotify] Playing..."); } -} +}; -export async function nextTrack() { - const spotifyApi = await initSpotify(); - if (!spotifyApi) return; +/** + * Skips to the next track in the Spotify playback. + * @returns void + */ +export const nextTrack = async () => { + if (!isSpotifyInitialized()) return; await spotifyApi.skipToNext(); console.log("[Spotify] Next track."); -} - -export async function previousTrack() { - const spotifyApi = await initSpotify(); - if (!spotifyApi) return; +}; +/** + * Skips to the previous track in the Spotify playback. + * @returns void + */ +export const previousTrack = async () => { + if (!isSpotifyInitialized()) return; await spotifyApi.skipToPrevious(); console.log("[Spotify] Previous track."); -} +}; + +const isSpotifyInitialized = (): boolean => { + if (!spotifyApi) { + console.warn("[Spotify] Not initialized."); + return false; + } + return true; +}; diff --git a/src/spotify/config/database.ts b/src/spotify/config/database.ts index 9da8640..265dfaa 100644 --- a/src/spotify/config/database.ts +++ b/src/spotify/config/database.ts @@ -1,17 +1,19 @@ -import fs from "fs"; import { join } from "path"; import { Database } from "bun:sqlite"; -import { binDir } from "../../shared/shared"; +import { binDir, isProduction } from "../../shared/shared"; const dbPath = join(binDir, "keyspotic.db"); const db = new Database(dbPath); -const migrationPath = join( - binDir, - "migrations", - "001_create_spotify_tokens_table.sql" -); +const migrationPath = isProduction + ? join(binDir, "migrations", "001_create_spotify_tokens_table.sql") + : join( + process.cwd(), + "src", + "migrations", + "001_create_spotify_tokens_table.sql" + ); const queries = { - createTokensTable: fs.readFileSync(migrationPath, "utf-8"), + createTokensTable: await Bun.file(migrationPath).text(), }; db.run(queries.createTokensTable); diff --git a/src/spotify/models/tokens/retrieveTokensResponse.ts b/src/spotify/models/tokens/retrieveTokensResponse.ts index 5a0d94a..0cecaac 100644 --- a/src/spotify/models/tokens/retrieveTokensResponse.ts +++ b/src/spotify/models/tokens/retrieveTokensResponse.ts @@ -1,3 +1,10 @@ +/** + * Represents the response containing retrieved tokens. + * @property {string} accessToken - The access token. + * @property {string} refreshToken - The refresh token. + * @property {number} expiresIn - The expiration time in milliseconds. + * + */ export type RetrieveTokensResponse = { accessToken: string; refreshToken: string; diff --git a/src/spotify/models/tokens/saveTokensRequest.ts b/src/spotify/models/tokens/saveTokensRequest.ts index 0e5cff0..2639a86 100644 --- a/src/spotify/models/tokens/saveTokensRequest.ts +++ b/src/spotify/models/tokens/saveTokensRequest.ts @@ -1,9 +1,20 @@ +/** + * Represents the request to save tokens. + * @property {string} accessToken - The access token. + * @property {string} refreshToken - The refresh token. + * @property {number} expiresIn - The expiration time in milliseconds. + * + */ export type SaveTokensRequest = { accessToken: string; refreshToken: string; expiresIn: number; }; - +/** + * Maps data to a SaveTokensRequest object. + * @param data - The data containing token information. + * @returns The mapped SaveTokensRequest object. + */ export const mapToSaveTokensRequest = (data: any): SaveTokensRequest => { return { accessToken: data.access_token, @@ -11,7 +22,11 @@ export const mapToSaveTokensRequest = (data: any): SaveTokensRequest => { expiresIn: data.expires_in, }; }; - +/** + * Maps a SaveTokensRequest object to a database row format. + * @param data - The SaveTokensRequest object. + * @returns The mapped database row format. + */ export const mapToDb = (data: SaveTokensRequest) => { return { access_token: data.accessToken, diff --git a/src/spotify/startup.ts b/src/spotify/startup.ts index 19aa290..49b053d 100644 --- a/src/spotify/startup.ts +++ b/src/spotify/startup.ts @@ -1,7 +1,7 @@ import { spotifyAuthRoutes } from "./routes/main"; -import { scopes, spotifyApi } from "./config/spotify"; import { retrieveCertPaths } from "../auth/certHelper"; import { tokenExists } from "./services/database/tokensServices"; +import { scopes, spotifyApi } from "./config/spotify"; /** * Initializes Spotify authentication by retrieving existing tokens * and setting up the HTTPS server for OAuth callbacks. From 15215bf039a1f89b60994b52a6135e33697626bd Mon Sep 17 00:00:00 2001 From: SammyBits Date: Sat, 1 Nov 2025 22:23:40 -0400 Subject: [PATCH 9/9] feat: restructure Spotify module and implement token management with database integration --- src/index.ts | 4 +- src/listeners/hotkeys.ts | 76 ++++++------------- src/{ => modules}/auth/certHelper.ts | 2 +- src/modules/hotkey/core/engine.ts | 37 +++++++++ src/modules/hotkey/core/state.ts | 53 +++++++++++++ src/modules/hotkey/core/transform.ts | 40 ++++++++++ src/{ => modules}/spotify/commands/main.ts | 2 +- src/{ => modules}/spotify/commands/player.ts | 0 src/{ => modules}/spotify/config/database.ts | 2 +- src/{ => modules}/spotify/config/spotify.ts | 0 .../models/tokens/retrieveTokensResponse.ts | 0 .../models/tokens/saveTokensRequest.ts | 0 src/{ => modules}/spotify/routes/main.ts | 0 .../services/database/tokensServices.ts | 0 src/{ => modules}/spotify/startup.ts | 0 .../spotify/useCases/saveTokensUseCases.ts | 2 +- 16 files changed, 159 insertions(+), 59 deletions(-) rename src/{ => modules}/auth/certHelper.ts (86%) create mode 100644 src/modules/hotkey/core/engine.ts create mode 100644 src/modules/hotkey/core/state.ts create mode 100644 src/modules/hotkey/core/transform.ts rename src/{ => modules}/spotify/commands/main.ts (94%) rename src/{ => modules}/spotify/commands/player.ts (100%) rename src/{ => modules}/spotify/config/database.ts (88%) rename src/{ => modules}/spotify/config/spotify.ts (100%) rename src/{ => modules}/spotify/models/tokens/retrieveTokensResponse.ts (100%) rename src/{ => modules}/spotify/models/tokens/saveTokensRequest.ts (100%) rename src/{ => modules}/spotify/routes/main.ts (100%) rename src/{ => modules}/spotify/services/database/tokensServices.ts (100%) rename src/{ => modules}/spotify/startup.ts (100%) rename src/{ => modules}/spotify/useCases/saveTokensUseCases.ts (93%) diff --git a/src/index.ts b/src/index.ts index e5cc7f8..4765c0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { startListener } from "./listeners/hotkeys"; -import { initSpotifyAuth } from "./spotify/startup"; -import { spotifyCommands } from "./spotify/commands/main"; +import { initSpotifyAuth } from "./modules/spotify/startup"; +import { spotifyCommands } from "./modules/spotify/commands/main"; const allCommands = [...spotifyCommands]; diff --git a/src/listeners/hotkeys.ts b/src/listeners/hotkeys.ts index d933626..aae446f 100644 --- a/src/listeners/hotkeys.ts +++ b/src/listeners/hotkeys.ts @@ -2,62 +2,32 @@ import { GlobalKeyboardListener, IGlobalKeyEvent, } from "node-global-key-listener"; -import { Command } from "../spotify/commands/main"; - -function normalizeModifier(key: string) { - if (!key) return ""; - if (key.includes("CTRL")) return "CTRL"; - if (key.includes("ALT")) return "ALT"; - if (key.includes("SHIFT")) return "SHIFT"; - if (key.includes("META")) return "META"; - return key.toUpperCase(); -} - -function buildCombo(heldModifiers: Set, key: string) { - // Order fixed for consistency: CTRL + ALT + SHIFT + META + key - const order = ["CTRL", "ALT", "SHIFT", "META"]; - const mods = order.filter((m) => heldModifiers.has(m)); - return mods.length - ? [...mods, key.toUpperCase()].join(" + ") - : key.toUpperCase(); -} - -export function startListener(commands: Command[]) { +import { Command } from "../modules/hotkey/core/engine"; +import { initialState, updateState } from "../modules/hotkey/core/state"; +import { executeCommands } from "../modules/hotkey/core/engine"; + +const IGNORED_EVENTS = ["MOUSE"]; + +/** + * Starts the global hotkey listener and processes key events. + * @param commands The list of registered commands to execute on hotkey presses. + * @returns A function to stop the listener. + */ +export const startListener = (commands: Command[]) => { const keyboard = new GlobalKeyboardListener(); - const heldModifiers = new Set(); - const loggedCombos = new Set(); - - const MODIFIERS = new Set([ - "LEFT CTRL", - "RIGHT CTRL", - "LEFT ALT", - "RIGHT ALT", - "LEFT SHIFT", - "RIGHT SHIFT", - ]); - - keyboard.addListener((e: IGlobalKeyEvent) => { - if (!e.name || e.name.includes("MOUSE")) return; + let internalState = initialState(); - const key = e.name.toUpperCase(); + keyboard.addListener((event: IGlobalKeyEvent) => { + if (!event.name || IGNORED_EVENTS.includes(event.name)) return; - if (e.state === "DOWN") { - if (MODIFIERS.has(key)) { - heldModifiers.add(normalizeModifier(key)); - } else { - const combo = buildCombo(heldModifiers, key); + const data = { name: event.name, state: event.state }; + // Transform the state immutably + internalState = updateState(data, internalState); - if (!loggedCombos.has(combo)) { - loggedCombos.add(combo); + // Evaluate the current state and execute commands + const actions = executeCommands(internalState, data, commands); - const cmd = commands.find((c) => c.hotkey === combo); - if (cmd) cmd.action(); - } - } - } else if (e.state === "UP") { - const normalizedKey = normalizeModifier(key); - if (MODIFIERS.has(key)) heldModifiers.delete(normalizedKey); - else loggedCombos.clear(); - } + // Execute all matched actions + actions.forEach((act) => act()); }); -} +}; diff --git a/src/auth/certHelper.ts b/src/modules/auth/certHelper.ts similarity index 86% rename from src/auth/certHelper.ts rename to src/modules/auth/certHelper.ts index 0b526ad..0eabb84 100644 --- a/src/auth/certHelper.ts +++ b/src/modules/auth/certHelper.ts @@ -1,4 +1,4 @@ -import { binDir } from "../shared/shared"; +import { binDir } from "../../shared/shared"; import { join } from "path"; /** diff --git a/src/modules/hotkey/core/engine.ts b/src/modules/hotkey/core/engine.ts new file mode 100644 index 0000000..5dd3491 --- /dev/null +++ b/src/modules/hotkey/core/engine.ts @@ -0,0 +1,37 @@ +import { ListenerState } from "./state"; +/** + * Represents a command with its associated hotkey and action. + */ +export interface Command { + hotkey: string; + action: () => void; +} + +/** + * Represents an engine event for key actions. + * @property name The name of the key. + * @property state The state of the key, either "UP" or "DOWN". + */ +export interface EngineEvent { + name: string; + state: "UP" | "DOWN"; +} +/** + * Executes the commands associated with the currently pressed hotkeys. + * @param state The current listener state. + * @param event The key event to process. + * @param commands The list of registered commands. + * @returns An array of functions to execute the matched commands. + */ +export const executeCommands = ( + state: ListenerState, + event: EngineEvent, + commands: Command[] +): (() => void)[] => { + if (!event.name) return []; + + const pressedCombos = Array.from(state.combos); + return commands + .filter((cmd) => pressedCombos.includes(cmd.hotkey)) + .map((cmd) => cmd.action); +}; diff --git a/src/modules/hotkey/core/state.ts b/src/modules/hotkey/core/state.ts new file mode 100644 index 0000000..dcf3f19 --- /dev/null +++ b/src/modules/hotkey/core/state.ts @@ -0,0 +1,53 @@ +import { buildCombo, isModifierKey, normalizeModifier } from "./transform"; +/** + * State of the hotkey listener, tracking held keys and active combos. + * + */ +export interface ListenerState { + held: Set; + combos: Set; +} +/** + * Initializes the listener state with empty held keys and combos. + * @returns The initial listener state. + */ +export const initialState = (): ListenerState => ({ + held: new Set(), + combos: new Set(), +}); +/** + * Updates the listener state based on the incoming key event. + * @param event The key event to process. + * @param prev The previous listener state. + * @returns The updated listener state. + */ +export const updateState = ( + event: { name: string; state: "UP" | "DOWN" }, + prev: ListenerState +): ListenerState => { + const next = { + held: new Set(prev.held), + combos: new Set(prev.combos), + }; + + const key = event.name.toUpperCase(); + const isMod = isModifierKey(key); + + if (event.state === "DOWN") { + if (isMod) { + next.held.add(normalizeModifier(key)); + return next; + } + + const combo = buildCombo(next.held, key); + next.combos.add(combo); + return next; + } + + if (event.state === "UP") { + if (isMod) next.held.delete(normalizeModifier(key)); + else next.combos.clear(); + } + + return next; +}; diff --git a/src/modules/hotkey/core/transform.ts b/src/modules/hotkey/core/transform.ts new file mode 100644 index 0000000..6332412 --- /dev/null +++ b/src/modules/hotkey/core/transform.ts @@ -0,0 +1,40 @@ +/** + * Normalizes various modifier key names to standard forms. + * @param key The key name to normalize. + * @returns The normalized key name. + */ +export const normalizeModifier = (key: string): string => { + if (!key) return ""; + const map = { + CTRL: ["LEFT CTRL", "RIGHT CTRL", "CTRL"], + ALT: ["LEFT ALT", "RIGHT ALT", "ALT"], + SHIFT: ["LEFT SHIFT", "RIGHT SHIFT", "SHIFT"], + META: ["LEFT META", "RIGHT META", "META", "SUPER", "WINDOWS"], + } as const; + + for (const [normalized, variants] of Object.entries(map)) { + if (variants.some((v) => key.includes(v))) return normalized; + } + + return key.toUpperCase(); +}; + +/** + * Checks if the given key is a modifier key. + * @param key The key name to check. + * @returns True if the key is a modifier key, false otherwise. + */ +export const isModifierKey = (key: string): boolean => + ["CTRL", "ALT", "SHIFT", "META"].some((m) => key.includes(m)); + +/** + * Builds a combo string from held modifier keys and the main key. + * @param held The set of currently held modifier keys. + * @param key The main key to include in the combo. + * @returns The constructed combo string. + */ +export const buildCombo = (held: Set, key: string): string => { + const order = ["CTRL", "ALT", "SHIFT", "META"]; + const mods = order.filter((m) => held.has(m)); + return [...mods, key.toUpperCase()].join(" + "); +}; diff --git a/src/spotify/commands/main.ts b/src/modules/spotify/commands/main.ts similarity index 94% rename from src/spotify/commands/main.ts rename to src/modules/spotify/commands/main.ts index b70dc24..e5b1f63 100644 --- a/src/spotify/commands/main.ts +++ b/src/modules/spotify/commands/main.ts @@ -1,4 +1,4 @@ -import { binDir } from "../../shared/shared"; +import { binDir } from "../../../shared/shared"; import { playOrPause, nextTrack, previousTrack } from "./player"; import { join } from "path"; diff --git a/src/spotify/commands/player.ts b/src/modules/spotify/commands/player.ts similarity index 100% rename from src/spotify/commands/player.ts rename to src/modules/spotify/commands/player.ts diff --git a/src/spotify/config/database.ts b/src/modules/spotify/config/database.ts similarity index 88% rename from src/spotify/config/database.ts rename to src/modules/spotify/config/database.ts index 265dfaa..2411ddd 100644 --- a/src/spotify/config/database.ts +++ b/src/modules/spotify/config/database.ts @@ -1,6 +1,6 @@ import { join } from "path"; import { Database } from "bun:sqlite"; -import { binDir, isProduction } from "../../shared/shared"; +import { binDir, isProduction } from "../../../shared/shared"; const dbPath = join(binDir, "keyspotic.db"); const db = new Database(dbPath); diff --git a/src/spotify/config/spotify.ts b/src/modules/spotify/config/spotify.ts similarity index 100% rename from src/spotify/config/spotify.ts rename to src/modules/spotify/config/spotify.ts diff --git a/src/spotify/models/tokens/retrieveTokensResponse.ts b/src/modules/spotify/models/tokens/retrieveTokensResponse.ts similarity index 100% rename from src/spotify/models/tokens/retrieveTokensResponse.ts rename to src/modules/spotify/models/tokens/retrieveTokensResponse.ts diff --git a/src/spotify/models/tokens/saveTokensRequest.ts b/src/modules/spotify/models/tokens/saveTokensRequest.ts similarity index 100% rename from src/spotify/models/tokens/saveTokensRequest.ts rename to src/modules/spotify/models/tokens/saveTokensRequest.ts diff --git a/src/spotify/routes/main.ts b/src/modules/spotify/routes/main.ts similarity index 100% rename from src/spotify/routes/main.ts rename to src/modules/spotify/routes/main.ts diff --git a/src/spotify/services/database/tokensServices.ts b/src/modules/spotify/services/database/tokensServices.ts similarity index 100% rename from src/spotify/services/database/tokensServices.ts rename to src/modules/spotify/services/database/tokensServices.ts diff --git a/src/spotify/startup.ts b/src/modules/spotify/startup.ts similarity index 100% rename from src/spotify/startup.ts rename to src/modules/spotify/startup.ts diff --git a/src/spotify/useCases/saveTokensUseCases.ts b/src/modules/spotify/useCases/saveTokensUseCases.ts similarity index 93% rename from src/spotify/useCases/saveTokensUseCases.ts rename to src/modules/spotify/useCases/saveTokensUseCases.ts index 4135e73..b54e66e 100644 --- a/src/spotify/useCases/saveTokensUseCases.ts +++ b/src/modules/spotify/useCases/saveTokensUseCases.ts @@ -1,4 +1,4 @@ -import { err, ok, Result } from "../../shared/result"; +import { err, ok, Result } from "../../../shared/result"; import { spotifyApi } from "../config/spotify"; import { SaveTokensRequest } from "../models/tokens/saveTokensRequest"; import { saveTokens } from "../services/database/tokensServices";