Repository: taliyahwebb/curses
Pull request #34: rework Twitch auth
Author: login: Pietagorh
Labels: name: bug
description: Something isn't working
Created: 2025-03-23T18:24:26Z | Merged: 2025-03-25T12:54:39Z

fixes #31 

- use tauri window for Twitch auth
- add a valid Twitch client id
- use the correct port when not using default (and some doc if it doesn't work)
- add create webview capability to main

Linked issue #31: Twitch doesn't open popup window on Linux
this is probably due do `window.open` not being fully supported by tauri
will have to re-implement with [webviewWindow | Tauri](https://v2.tauri.app/reference/javascript/api/namespacewebviewwindow/)

Diff:
diff --git a/.env b/.env
index 448600dc..b71577f6 100644
--- a/.env
+++ b/.env
@@ -1,2 +1 @@
-CURSES_TWITCH_CLIENT_REDIRECT_LOCAL=http://localhost:3030/oauth_twitch.html
-
+CURSES_TWITCH_CLIENT_ID=pfefq8km2buzurwsdifydhrjtefxeg
diff --git a/README.md b/README.md
index eda71f54..9c1b5f1b 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,8 @@
     - [TikTok](#tiktok)
     - [Uberduck](#uberduck)
     - [Custom TTS](#custom-tts)
+  - [Twitch](#twitch)
+    - [Using custom ports](#using-custom-ports)
 - [Building](#building)
   - [Prerequisites](#prerequisites)
     - [NixOS](#nixos)
@@ -228,6 +230,14 @@ There are more advanced options for Windows users depending on the extension of
 (where `%script%` is the absolute path to the script)
 </details>
 
+## Twitch
+### Using custom ports
+If you are using a custom port (i.e. running `curses --port {your port}`), Twitch authentication might not work. This is because Twitch only allows a few static URLs as redirects, ports included.
+
+If you are unable to use the default port (3030) for this one-time operation, you can try with any of 45561-45569.
+
+If this is not an option for you, you can [create your own app](https://dev.twitch.tv/docs/authentication/register-app/). Set the OAuth Redirect URL to `http://localhost:{your port}/oauth_twitch.html`, and the client type to `Public`. Then, pass the `CURSES_TWITCH_CLIENT_ID` env variable with the newly-generated client ID when running Curses.
+
 # Building
 ## Prerequisites
 **Application framework dependencies**:
diff --git a/public/oauth_twitch.html b/public/oauth_twitch.html
index 0a532199..adbe222c 100644
--- a/public/oauth_twitch.html
+++ b/public/oauth_twitch.html
@@ -5,18 +5,24 @@
   <title>Twitch auth</title>
 </head>
 <body>
+  <p id="text">Redirected</p>
 
 <script>
-  const bc = new BroadcastChannel('twitch_oauth');
-  if (document.location.hash && document.location.hash != '') {
-    var parsedHash = new URLSearchParams(window.location.hash.substring(1));
-    if (parsedHash.get('access_token')) {
-      var access_token = parsedHash.get('access_token');
-      parsedHash.get('access_token') && bc.postMessage(access_token ?? null);
-      access_token && window.opener?.postMessage(`smplstt_tw_auth:${access_token}`, '*');
-    }
+  function main() {
+    if (!document.location.hash)
+      document.getElementById("text").innerHTML = "This is only meant to be opened as a twitch redirect!";
+
+    const parsedHash = new URLSearchParams(window.location.hash.substring(1));
+    const accessToken = parsedHash.get("access_token") ?? "";
+
+    const tauriWindow = window.__TAURI__.window.getCurrentWindow();
+    tauriWindow.emit("twitch_token", accessToken)
+      .then(() => tauriWindow.close());
   }
-  window.close();
+
+  // temporary fix for https://github.com/tauri-apps/tauri/issues/12990
+  // this can be removed and the code directly executed once the issue is resolved
+  setTimeout(main, 1000);
 </script>
 </body>
 </html>
diff --git a/src-tauri/capabilities/manage-webviews.json b/src-tauri/capabilities/manage-webviews.json
new file mode 100644
index 00000000..f734102f
--- /dev/null
+++ b/src-tauri/capabilities/manage-webviews.json
@@ -0,0 +1,10 @@
+{
+  "identifier": "manage-webviews",
+  "description": "The set of webview-related permissions needed for the main window to manage webviews",
+  "windows": [
+    "main"
+  ],
+  "permissions": [
+    "core:webview:allow-create-webview-window"
+  ]
+}
diff --git a/src-tauri/capabilities/oauth-twitch.json b/src-tauri/capabilities/oauth-twitch.json
new file mode 100644
index 00000000..d73d82c0
--- /dev/null
+++ b/src-tauri/capabilities/oauth-twitch.json
@@ -0,0 +1,14 @@
+{
+  "identifier": "oauth-twitch",
+  "description": "Permissions for the twitch authentication window",
+  "webviews": [
+    "oauth_twitch"
+  ],
+  "permissions": [
+    "core:window:allow-close",
+    "core:event:allow-emit"
+  ],
+  "remote": {
+    "urls": ["http://localhost:*/*"]
+  }
+}
diff --git a/src/server/services/twitch/index.ts b/src/server/services/twitch/index.ts
index ab6b1626..15667c64 100644
--- a/src/server/services/twitch/index.ts
+++ b/src/server/services/twitch/index.ts
@@ -1,4 +1,6 @@
 import { IServiceInterface, ServiceNetworkState, TextEventType } from "@/types";
+import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
+import { Event, UnlistenFn } from "@tauri-apps/api/event";
 import { ApiClient, HelixUser } from "@twurple/api";
 import { StaticAuthProvider } from "@twurple/auth";
 import { proxy } from "valtio";
@@ -9,6 +11,8 @@ import {
 } from "../../../utils";
 import TwitchChatApi from "./chat";
 import TwitchEmotesApi from "./emotes";
+import { toast } from "react-toastify";
+import { useTranslation } from "react-i18next";
 const scope = ["chat:read", "chat:edit", "channel:read:subscriptions"];
 
 class Service_Twitch implements IServiceInterface {
@@ -22,6 +26,8 @@ class Service_Twitch implements IServiceInterface {
 
   apiClient?: ApiClient;
 
+  unlistener: UnlistenFn = () => {};
+
   state = proxy<{
     user: HelixUser | null;
     liveStatus: ServiceNetworkState;
@@ -76,46 +82,42 @@ class Service_Twitch implements IServiceInterface {
     });
   }
 
-  login() {
-    try {
-      const redirect =
-        import.meta.env.MODE === "development"
-          ? "http://localhost:1420/oauth_twitch.html"
-          : import.meta.env.CURSES_TWITCH_CLIENT_REDIRECT_LOCAL;
-
-      const link = new URL("https://id.twitch.tv/oauth2/authorize");
-      link.searchParams.set(
-        "client_id",
-        import.meta.env.CURSES_TWITCH_CLIENT_ID
-      );
-      link.searchParams.set("redirect_uri", redirect);
-      link.searchParams.set("response_type", "token");
-      link.searchParams.set("scope", scope.join("+"));
-      link.search = decodeURIComponent(link.search);
-
-      const auth_window = window.open(link, "", "width=600,height=600");
-      const thisRef = this;
-
-      const handleMessage = (msg: MessageEvent<unknown>) => {
-        if (
-          typeof msg.data === "string" &&
-          msg.data.startsWith("smplstt_tw_auth:")
-        ) {
-          const access_token = msg.data.split(":")[1];
-          if (typeof access_token === "string") {
-            thisRef.#state.data.token = access_token;
-            thisRef.connect();
-            window.removeEventListener("message", handleMessage, true);
-          }
-        }
-      };
-      if (auth_window) {
-        window.addEventListener("message", handleMessage, true);
-        auth_window.onbeforeunload = () => {
-          window?.removeEventListener("message", (m) => handleMessage(m), true);
-        };
-      }
-    } catch (error) {}
+  async login() {
+    const {t} = useTranslation();
+    // Using [Implicit grant flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#implicit-grant-flow).
+    const link = new URL("https://id.twitch.tv/oauth2/authorize");
+    link.searchParams.set(
+      "client_id",
+      import.meta.env.CURSES_TWITCH_CLIENT_ID
+    );
+    link.searchParams.set(
+      "redirect_uri",
+      `http://localhost:${window.Config.serverNetwork.port}/oauth_twitch.html`
+    );
+    link.searchParams.set("response_type", "token");
+    link.searchParams.set("scope", scope.join("+"));
+    link.search = decodeURIComponent(link.search);
+
+    const auth_window = new WebviewWindow("oauth_twitch", {
+      url: link.toString(),
+      width: 600,
+      height: 600,
+      title: t('twitch.title'),
+      devtools: import.meta.env.DEV, // true if vite is in dev mode
+    });
+    auth_window.once("tauri://created", () => {});
+    auth_window.once("tauri://error", (err) => toast.error(`Error creating window: ${err.payload}`));
+
+    const thisRef = this;
+    const handleEvent = (event: Event<string>) => {
+      thisRef.unlistener();
+
+      thisRef.#state.data.token = event.payload;
+      thisRef.connect();
+    };
+
+    this.unlistener(); // in case there was somehow an uncalled unlistener
+    this.unlistener = await auth_window.listen<string>("twitch_token", handleEvent);
   }
 
   logout() {
diff --git a/src/server/ui/inspector/inspector_twitch.tsx b/src/server/ui/inspector/inspector_twitch.tsx
index c87cc51f..c074a533 100644
--- a/src/server/ui/inspector/inspector_twitch.tsx
+++ b/src/server/ui/inspector/inspector_twitch.tsx
@@ -63,7 +63,7 @@ const Inspector_Twitch: FC = () => {
   const {t} = useTranslation();
   const {user, liveStatus} = useSnapshot(window.ApiServer.twitch.state);
   const chatState = useSnapshot(window.ApiServer.twitch.chat.state);
-  const handleLogin = () => window.ApiServer.twitch.login();
+  const handleLogin = () => window.ApiServer.twitch.login().then();
   const handleLogout = () => window.ApiServer.twitch.logout();
   const handleShowEmotes = () => NiceModal.show('twitch-emotes');
   const handleShowEmotesMapper = () => NiceModal.show('twitch-emotes-map');


Reviews:
user: login: taliyahwebb
body: looks functional
maybe merge create-webview perms into a central perms file or create a new one to organize needed permissions in
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-24T09:07:53Z
user: login: Pietagorh
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-24T10:00:17Z
user: login: taliyahwebb
body: LGTM, please test on windows and linux then feel free to merge
state: APPROVED
author_association: OWNER
submitted_at: 2025-03-24T10:15:47Z

Review comments:
path: src-tauri/capabilities/create-webview.json
user: login: taliyahwebb
body: maybe add that to some "central" capabilities file for window 'main' (like migrated)
if we do a new file for each new cap. we will clutter that dir
created_at: 2025-03-24T09:06:29Z
author_association: OWNER
path: src-tauri/capabilities/create-webview.json
user: login: Pietagorh
body: From what i could gather, the encouraged behavior was creating a file for each functionality 
But maybe renaming it to "manage-weviews" would be better for later use
created_at: 2025-03-24T10:00:17Z
author_association: COLLABORATOR

PR comments:
user: login: Pietagorh
created_at: 2025-03-24T10:18:41Z
body: already done! gonna rename the capabilities file before merging
author_association: COLLABORATOR