diff --git a/tests/test_auth.py b/tests/test_auth.py index 0ef95a8..8010c08 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -349,6 +349,22 @@ def test_diagnose_keychain_issues_ssh_hint(monkeypatch) -> None: assert "security unlock-keychain" in hint +def test_diagnose_keychain_issues_windows_hint(monkeypatch) -> None: + """On Windows, hint should mention DPAPI and environment variable workaround.""" + monkeypatch.setattr("sys.platform", "win32") + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + + diagnostics = ["chrome: Unable to get key for cookie decryption"] + hint = auth._diagnose_keychain_issues(diagnostics) + + assert hint is not None + assert "DPAPI" in hint + assert "TWITTER_AUTH_TOKEN" in hint + assert "shadowcopy" in hint + + def test_diagnose_keychain_issues_returns_none_for_unrelated_errors() -> None: """Should return None when diagnostics don't mention Keychain.""" diagnostics = ["chrome[Default]=no-cookies", "firefox: profile not found"] diff --git a/twitter_cli/auth.py b/twitter_cli/auth.py index f9d8f61..32b1519 100644 --- a/twitter_cli/auth.py +++ b/twitter_cli/auth.py @@ -65,6 +65,15 @@ def _diagnose_keychain_issues(diagnostics: List[str]) -> Optional[str]: " Fix: Open Keychain Access → search for \" Safe Storage\" → Access Control → add your Terminal app.\n" " Or click \"Always Allow\" when the Keychain authorization popup appears." ) + if sys.platform == "win32": + return ( + "Windows DPAPI cookie decryption failed.\n" + " Possible causes:\n" + " 1. Chrome is running (locks the cookie database). Try closing Chrome first.\n" + " 2. browser_cookie3 may not support the latest Chrome cookie encryption format.\n" + " 3. If Chrome was running, admin privileges may be required for VSS shadowcopy.\n" + " Workaround: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables manually." + ) # Linux: gnome-keyring / SecretStorage issues return ( "System keyring access failed — the cookie encryption key could not be retrieved.\n" diff --git a/twitter_cli/formatter.py b/twitter_cli/formatter.py index 0912dd6..44564f0 100644 --- a/twitter_cli/formatter.py +++ b/twitter_cli/formatter.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sys from typing import List, Optional from rich.console import Console @@ -13,6 +14,18 @@ from .models import Tweet, UserProfile from .timeutil import format_local_time, format_relative_time +def _make_console() -> Console: + """Create a Console that works correctly on Windows pipes. + + On Windows, rich may use WriteConsoleW API directly instead of writing + to stdout, making output invisible to pipe/subprocess capture. + Using force_terminal=False in non-TTY contexts prevents this. + """ + if sys.platform == "win32" and not sys.stdout.isatty(): + return Console(force_terminal=False) + return Console() + + def format_number(n: int) -> str: """Format number with K/M suffixes.""" if n >= 1_000_000: @@ -30,7 +43,7 @@ def print_tweet_table( ) -> None: """Print tweets as a rich table.""" if console is None: - console = Console() + console = _make_console() if not title: title = "📱 Twitter — %d tweets" % len(tweets) @@ -101,7 +114,7 @@ def print_tweet_table( def print_tweet_detail(tweet: Tweet, console: Optional[Console] = None) -> None: """Print a single tweet in detail using a rich panel.""" if console is None: - console = Console() + console = _make_console() verified = " ✓" if tweet.author.verified else "" header = "@%s%s (%s)" % (tweet.author.screen_name, verified, tweet.author.name) @@ -181,7 +194,7 @@ def article_to_markdown(tweet: Tweet) -> str: def print_article(tweet: Tweet, console: Optional[Console] = None) -> None: """Print a Twitter Article with rich formatting.""" if console is None: - console = Console() + console = _make_console() verified = " ✓" if tweet.author.verified else "" title = tweet.article_title or "Twitter Article" @@ -218,7 +231,7 @@ def print_filter_stats( ) -> None: """Print filter statistics.""" if console is None: - console = Console() + console = _make_console() console.print( "📊 Filter: %d → %d tweets" % (original_count, len(filtered)) @@ -234,7 +247,7 @@ def print_filter_stats( def print_user_profile(user: UserProfile, console: Optional[Console] = None) -> None: """Print user profile as a rich panel.""" if console is None: - console = Console() + console = _make_console() verified = " ✓" if user.verified else "" header = "@%s%s (%s)" % (user.screen_name, verified, user.name) @@ -280,7 +293,7 @@ def print_user_table( ) -> None: """Print a list of users as a rich table.""" if console is None: - console = Console() + console = _make_console() if not title: title = "👥 Users — %d" % len(users)