- 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:
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user