diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e06d6fe..ea398b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,10 +39,11 @@ permissions: # If there's a prerelease-style suffix to the version, then the release(s) # will be marked as a prerelease. on: + workflow_dispatch: pull_request: push: tags: - - '**[0-9]+.[0-9]+.[0-9]+*' + - 'v*.*.*' jobs: # Run 'dist plan' (or host) to determine what tasks we need to do diff --git a/CHANGELOG.md b/CHANGELOG.md index 919418d..5867827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] - Nothing yet. +## [0.2.10] - 2026-05-03 +### Added +- Optional cookie authentication mode using `TOKEN_V2` and `REDDIT_SESSION` from an existing browser session. +- Cookie auth setup documentation and a `cookie_probe` example for checking credentials before opening the TUI. +### Fixed +- Apple Terminal now disables Kitty image escape output automatically. + ## [0.2.9] - 2025-11-05 ### Added - Optional `ui.cell_width` and `ui.cell_height` overrides so you can pin custom terminal cell metrics when needed. @@ -164,7 +171,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added - Initial release with the polished login workflow, refreshed caching, and improved feed pagination. -[Unreleased]: https://github.com/ck-zhang/reddix/compare/v0.2.9...HEAD +[Unreleased]: https://github.com/natekettles/reddix/compare/v0.2.10...HEAD +[0.2.10]: https://github.com/natekettles/reddix/compare/v0.2.9...v0.2.10 [0.2.9]: https://github.com/ck-zhang/reddix/compare/v0.2.8...v0.2.9 [0.2.8]: https://github.com/ck-zhang/reddix/compare/v0.2.7...v0.2.8 [0.2.7]: https://github.com/ck-zhang/reddix/compare/v0.2.6...v0.2.7 diff --git a/Cargo.lock b/Cargo.lock index 72acd6b..77bd9cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1732,7 +1732,7 @@ checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" [[package]] name = "reddix" -version = "0.2.9" +version = "0.2.10" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index fff9da3..4585fe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reddix" -version = "0.2.9" +version = "0.2.10" edition = "2021" license = "MIT" description = "Reddix - Reddit, refined for the terminal." diff --git a/README.md b/README.md index 410f0b8..d718b11 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,105 @@ -# Reddix +# Reddix Cookie Auth Fork -[![Release](https://img.shields.io/github/v/release/ck-zhang/reddix?style=flat-square)](https://github.com/ck-zhang/reddix/releases/latest) -[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE) +This is a fork of [ck-zhang/reddix](https://github.com/ck-zhang/reddix) that adds a personal-use cookie authentication mode. Thanks to [ck-zhang](https://github.com/ck-zhang) for creating Reddix. + +The upstream Reddix app uses Reddit OAuth. This fork can instead read your existing browser Reddit cookies from environment variables and use them to load Reddit in the terminal. This is not an official or Reddit-approved authentication method. Abuse, unusual traffic, or detection by Reddit could result in rate limiting, session invalidation, or an account ban. Reddix - Reddit, refined for the terminal. ![Reddix UI](docs/assets/reddix-ui-preview.png) -## Features +## Cookie Auth Quickstart -- Image previews based on the kitty graphics protocol -- Video playback via [mpv](https://mpv.io)'s Kitty integration -- Gallery browsing with inline navigation controls -- Multi-account support -- Keyboard-first navigation -- Smart caching -- NSFW filter toggle +For the best experience, run Reddix in a terminal that supports the Kitty graphics protocol. [Ghostty](https://ghostty.org/) is a good choice on macOS. Apple Terminal can run the app, but it cannot display inline images. + +1. Clone and build this fork: + +```sh +git clone https://github.com/natekettles/reddix.git +cd reddix +cargo build --release +``` + +2. Open Reddit in Chrome while logged in. + +3. Open Chrome DevTools: + +```text +View -> Developer -> Developer Tools +``` + +4. Go to: + +```text +Application -> Cookies -> https://www.reddit.com +``` -## Install +5. Copy the cookie values named `reddit_session` and `token_v2`. -### GitHub Releases +6. Export them in your shell: -You can download the latest [release](https://github.com/ck-zhang/reddix/releases/latest) from GitHub +```sh +export REDDIT_SESSION='your_reddit_session_cookie_value_here' +export TOKEN_V2='your_token_v2_cookie_value_here' +``` -### Use the install script: +7. Optionally persist them by adding those same two lines to `~/.zshrc` or `~/.bashrc`, then reload your shell: ```sh -curl --proto '=https' --tlsv1.2 -LsSf https://github.com/ck-zhang/reddix/releases/latest/download/reddix-installer.sh | sh +source ~/.zshrc ``` -### Install via Homebrew: +8. Test cookie auth without opening the TUI: ```sh -brew install reddix +cargo run --example cookie_probe +``` + +You should see output like: + +```text +children=1 ``` -### Install via AUR (Archlinux): -From source: +9. Run Reddix: + ```sh -yay -S reddix +cargo run --release ``` -Binaries: + +Or run the built binary directly: + ```sh -yay -S reddix-bin +./target/release/reddix ``` -## Quickstart +When `TOKEN_V2` is set, this fork automatically uses cookie auth and skips the OAuth setup flow. `REDDIT_SESSION` is optional for some reads, but setting both matches the browser session most reliably. + +## Notes + +- Cookie auth uses `https://www.reddit.com/` rather than the OAuth API host. +- Some Reddit responses may be rate limited. If the app shows a fetch error, wait a few minutes and retry. +- Inline image previews require a terminal that supports the Kitty graphics protocol, such as Ghostty or Kitty. +- Apple Terminal does not support Kitty graphics, so this fork disables those image escapes there. -1. Apply for a Reddit “script” via the [Reddit support form](https://support.reddithelp.com/hc/en-us/requests/new?ticket_form_id=14868593862164&tf_14867328473236=api_request_type_enterprise). Once approved, set the redirect URI to `http://127.0.0.1:65010/reddix/callback`. -2. Launch `reddix`, press `m`, and follow the guided menu for setup. -3. Prefer to configure things manually? Copy [`docs/examples/config.yaml`](docs/examples/config.yaml) into `~/.config/reddix/config.yaml` and fill in your credentials. +## Features -Note: As of Nov 2025, Reddit blocked the old `reddit.com/prefs/apps` flow. Apply via the [Reddit support form](https://support.reddithelp.com/hc/en-us/requests/new?ticket_form_id=14868593862164&tf_14867328473236=api_request_type_enterprise) (context: https://www.reddit.com/r/redditdev/comments/1oug31u/introducing_the_responsible_builder_policy_new/). +- Image previews based on the kitty graphics protocol +- Video playback via [mpv](https://mpv.io)'s Kitty integration +- Gallery browsing with inline navigation controls +- Multi-account support +- Keyboard-first navigation +- Smart caching +- NSFW filter toggle Core shortcuts: `j/k` move, `h/l` change panes, `m` guided menu, `o` action menu, `r` refresh, `s` sync subs, `u/d` vote, `q` quit. -## Support +## Upstream OAuth Setup + +The original Reddix OAuth flow is still present. If you want to use official OAuth instead of cookie auth, unset `TOKEN_V2` and follow the upstream setup approach: + +1. Apply for a Reddit app via the [Reddit support form](https://support.reddithelp.com/hc/en-us/requests/new?ticket_form_id=14868593862164&tf_14867328473236=api_request_type_enterprise). +2. Once approved, set the redirect URI to `http://127.0.0.1:65010/reddix/callback`. +3. Launch `reddix`, press `m`, and follow the guided menu for setup. -- I welcome feature requests and contributions; the project is still in its early stages. -- Track ongoing ideas in the [feature request log](docs/feature-requests.md). -- Donations: [https://ko-fi.com/ckzhang](https://ko-fi.com/ckzhang) +As of Nov 2025, Reddit blocked the old `reddit.com/prefs/apps` flow. Apply via the Reddit support form instead. diff --git a/dist-workspace.toml b/dist-workspace.toml index 77da8ac..55bb2d0 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -13,3 +13,6 @@ installers = ["shell", "powershell"] targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] # Where to host releases hosting = "github" +# This fork keeps a manual workflow_dispatch trigger and simpler tag glob so +# GitHub Actions can build release assets on demand. +allow-dirty = ["ci"] diff --git a/docs/release-notes.yaml b/docs/release-notes.yaml index a50646c..49828d1 100644 --- a/docs/release-notes.yaml +++ b/docs/release-notes.yaml @@ -1,4 +1,13 @@ release: + - version: "0.2.10" + title: "What's new in v0.2.10" + banner: "Cookie auth for personal Reddit sessions." + summary: "This fork can use your browser Reddit cookies when OAuth app approval is unavailable or delayed." + url: "https://github.com/natekettles/reddix/releases/tag/v0.2.10" + details: + - "Set TOKEN_V2 and optional REDDIT_SESSION to use cookie auth instead of OAuth." + - "Run cargo run --example cookie_probe to check credentials before opening the TUI." + - "Apple Terminal now disables Kitty image escapes automatically." - version: "0.2.9" title: "What's new in v0.2.9" banner: "Windows previews now scale correctly." diff --git a/examples/cookie_probe.rs b/examples/cookie_probe.rs new file mode 100644 index 0000000..564af1d --- /dev/null +++ b/examples/cookie_probe.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use anyhow::Result; +use reddix::reddit::{self, ListingOptions, SortOption, TokenProvider}; + +struct EnvTokenProvider { + access_token: String, +} + +impl TokenProvider for EnvTokenProvider { + fn token(&self) -> Result { + Ok(reddit::OAuthToken { + access_token: self.access_token.clone(), + token_type: "bearer".to_string(), + expires_at: None, + }) + } +} + +fn main() -> Result<()> { + let reddit_session = std::env::var("REDDIT_SESSION")?; + let token_v2 = std::env::var("TOKEN_V2")?; + let cookie_header = format!("reddit_session={reddit_session}; token_v2={token_v2}"); + let token_provider = Arc::new(EnvTokenProvider { + access_token: token_v2, + }); + let client = reddit::Client::new( + token_provider, + reddit::ClientConfig { + user_agent: "reddix-cookie-probe/0.1".to_string(), + base_url: Some("https://www.reddit.com/".to_string()), + http_client: None, + cookie_header: Some(cookie_header), + bearer_auth: false, + }, + )?; + let listing = client.front_page( + SortOption::Hot, + ListingOptions { + limit: Some(1), + ..Default::default() + }, + )?; + println!("children={}", listing.children.len()); + Ok(()) +} diff --git a/src/app.rs b/src/app.rs index ea01871..e50453e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::time::SystemTime; use anyhow::{Context, Result}; @@ -12,6 +13,20 @@ use crate::storage; use crate::theme; use crate::ui; +struct EnvTokenProvider { + access_token: String, +} + +impl reddit::TokenProvider for EnvTokenProvider { + fn token(&self) -> Result { + Ok(reddit::OAuthToken { + access_token: self.access_token.clone(), + token_type: "bearer".to_string(), + expires_at: None::, + }) + } +} + pub fn run() -> Result<()> { let cfg = config::load(config::LoadOptions::default()).context("load config")?; let config_path = config::default_path(); @@ -65,11 +80,65 @@ pub fn run() -> Result<()> { let mut session_manager: Option> = None; let mut fetch_subreddits_on_start = false; + let reddit_session = std::env::var("REDDIT_SESSION") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let token_v2 = std::env::var("TOKEN_V2") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let login_ready = !cfg.reddit.client_id.trim().is_empty() && !cfg.reddit.user_agent.trim().is_empty() && !cfg.reddit.redirect_uri.trim().is_empty(); - if login_ready { + if let Some(token_v2) = token_v2 { + let user_agent = if cfg.reddit.user_agent.trim().is_empty() { + format!("reddix/{} cookie-auth", env!("CARGO_PKG_VERSION")) + } else { + cfg.reddit.user_agent.clone() + }; + let mut cookie_parts = Vec::new(); + if let Some(reddit_session) = reddit_session { + cookie_parts.push(format!("reddit_session={reddit_session}")); + } + cookie_parts.push(format!("token_v2={token_v2}")); + + let token_provider: Arc = Arc::new(EnvTokenProvider { + access_token: token_v2, + }); + if let Ok(client) = reddit::Client::new( + token_provider, + reddit::ClientConfig { + user_agent, + base_url: Some("https://www.reddit.com/".to_string()), + http_client: None, + cookie_header: Some(cookie_parts.join("; ")), + bearer_auth: false, + }, + ) { + let client = Arc::new(client); + let subreddit_api: Arc = + Arc::new(data::RedditSubredditService::new(client.clone())); + let feed_api: Arc = + Arc::new(data::RedditFeedService::new(client.clone())); + let comment_api: Arc = + Arc::new(data::RedditCommentService::new(client.clone())); + let interaction_api: Arc = + Arc::new(data::RedditInteractionService::new(client.clone())); + + feed_service = Some(feed_api); + subreddit_service = Some(subreddit_api); + comment_service = Some(comment_api); + interaction_service = Some(interaction_api); + fetch_subreddits_on_start = true; + posts.clear(); + status = "Using Reddit cookie auth from TOKEN_V2/REDDIT_SESSION. Press q to quit." + .to_string(); + content = "Cookie auth mode is active. Loading subscribed feeds...".to_string(); + } + } else if login_ready { let flow_cfg = auth::Config { client_id: cfg.reddit.client_id.clone(), client_secret: cfg.reddit.client_secret.clone(), @@ -100,6 +169,8 @@ pub fn run() -> Result<()> { user_agent: cfg.reddit.user_agent.clone(), base_url: None, http_client: None, + cookie_header: None, + bearer_auth: true, }, ) { let client = Arc::new(client); diff --git a/src/reddit.rs b/src/reddit.rs index c4c27ec..7d77ad2 100644 --- a/src/reddit.rs +++ b/src/reddit.rs @@ -1,9 +1,10 @@ +use std::process::Command; use std::sync::Arc; use std::time::{Duration, SystemTime}; use anyhow::{anyhow, bail, Context, Result}; use reqwest::blocking::{Client as HttpClient, Response}; -use reqwest::header::{HeaderMap, AUTHORIZATION, CONTENT_TYPE, USER_AGENT}; +use reqwest::header::{HeaderMap, AUTHORIZATION, CONTENT_TYPE, COOKIE, USER_AGENT}; use reqwest::Method; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -29,6 +30,8 @@ pub struct ClientConfig { pub user_agent: String, pub base_url: Option, pub http_client: Option, + pub cookie_header: Option, + pub bearer_auth: bool, } #[derive(Debug, Clone, Default)] @@ -128,6 +131,8 @@ pub struct Client { http: HttpClient, user_agent: String, base_url: Url, + cookie_header: Option, + bearer_auth: bool, rate: RwLock, } @@ -159,6 +164,8 @@ impl Client { http, user_agent: config.user_agent, base_url, + cookie_header: config.cookie_header, + bearer_auth: config.bearer_auth, rate: RwLock::new(RateLimit::default()), }) } @@ -232,8 +239,7 @@ impl Client { }; let mut params = opts.into_params(); params.push(("sort".into(), sort.as_str().to_string())); - let resp = self.request(Method::GET, &path, ¶ms, None)?; - let payload: Vec = resp.json()?; + let payload: Vec = self.get_json(&path, ¶ms)?; if payload.len() < 2 { bail!("reddit: comments payload missing elements"); } @@ -323,8 +329,7 @@ impl Client { bail!("reddit: subreddit name required"); } let path = format!("/r/{}/about.json", name); - let resp = self.request(Method::GET, &path, &[], None)?; - let about: SubredditAboutEnvelope = resp.json()?; + let about: SubredditAboutEnvelope = self.get_json(&path, &[])?; Ok(about.data.user_is_subscriber) } @@ -366,11 +371,51 @@ impl Client { T: DeserializeOwned, { let params = opts.into_params(); - let resp = self.request(Method::GET, path, ¶ms, None)?; - let listing: ListingEnvelope = resp.json()?; + let listing: ListingEnvelope = self.get_json(path, ¶ms)?; Ok(listing.data) } + fn get_json(&self, path: &str, params: &[(String, String)]) -> Result + where + T: DeserializeOwned, + { + if self.cookie_header.is_none() { + let resp = self.request(Method::GET, path, params, None)?; + return Ok(resp.json()?); + } + + let mut url = self.base_url.join(path)?; + if !params.is_empty() { + let mut pairs = url.query_pairs_mut(); + for (k, v) in params { + pairs.append_pair(k, v); + } + } + + let cookie_header = self.cookie_header.as_ref().expect("checked above"); + let output = Command::new("curl") + .arg("-sS") + .arg("-L") + .arg("-A") + .arg(&self.user_agent) + .arg("-H") + .arg(format!("Cookie: {cookie_header}")) + .arg("-H") + .arg("accept: application/json,text/plain,*/*") + .arg("-H") + .arg("accept-language: en-US,en;q=0.9") + .arg(url.as_str()) + .output() + .context("reddit: run curl transport")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("reddit: curl transport failed: {}", stderr.trim()); + } + + serde_json::from_slice(&output.stdout).context("reddit: decode curl transport response") + } + fn request( &self, method: Method, @@ -390,9 +435,20 @@ impl Client { } let mut req = self.http.request(method, url); - let auth_value = format!("Bearer {}", token.access_token); req = req.header(USER_AGENT, self.user_agent.clone()); - req = req.header(AUTHORIZATION, auth_value); + if self.cookie_header.is_some() { + req = req + .header("accept", "application/json,text/plain,*/*") + .header("accept-language", "en-US,en;q=0.9") + .header("referer", "https://www.reddit.com/"); + } + if self.bearer_auth { + let auth_value = format!("Bearer {}", token.access_token); + req = req.header(AUTHORIZATION, auth_value); + } + if let Some(cookie_header) = &self.cookie_header { + req = req.header(COOKIE, cookie_header.clone()); + } if let Some(form_data) = form { req = req.header(CONTENT_TYPE, "application/x-www-form-urlencoded"); req = req.form(&form_data); diff --git a/src/ui.rs b/src/ui.rs index d76f79a..e038398 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3126,6 +3126,9 @@ fn determine_initial_kitty_status() -> KittyStatus { if env_truthy("REDDIX_DISABLE_KITTY") { return KittyStatus::ForcedDisabled; } + if running_inside_apple_terminal() { + return KittyStatus::ForcedDisabled; + } if env_truthy("REDDIX_FORCE_KITTY") { return KittyStatus::ForcedEnabled; } @@ -3148,6 +3151,12 @@ fn determine_initial_kitty_status() -> KittyStatus { KittyStatus::Unknown } +fn running_inside_apple_terminal() -> bool { + env::var("TERM_PROGRAM") + .map(|program| program == "Apple_Terminal") + .unwrap_or(false) +} + fn terminal_hints_kitty_support() -> bool { fn matches_known(value: &str) -> bool { let lower = value.to_ascii_lowercase(); @@ -7276,6 +7285,8 @@ impl Model { user_agent, base_url: None, http_client: None, + cookie_header: None, + bearer_auth: true, }, ) .context("create reddit client")?,