fix: Windows cookie diagnostics (#28) and rich output pipe capture (#29)

- Add win32 branch in _diagnose_keychain_issues() with DPAPI/admin/shadowcopy hints
- Add _make_console() helper in formatter.py with force_terminal=False for Windows non-TTY
- Replace all 6 Console() defaults with _make_console()
- Add test for Windows-specific diagnostics
This commit is contained in:
jackwener
2026-03-16 14:27:53 +08:00
parent 07e7f83e6f
commit 74386cebc8
3 changed files with 44 additions and 6 deletions

View File

@@ -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"]

View File

@@ -65,6 +65,15 @@ def _diagnose_keychain_issues(diagnostics: List[str]) -> Optional[str]:
" Fix: Open Keychain Access → search for \"<Browser> 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"

View File

@@ -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)