Skip to content

Commit 1bb08e6

Browse files
bezzadclaude
andcommitted
fix: x.com extraction + variant leak; Windows Start-menu shortcut (platform-followups)
- x.com (verified live on this machine): anonymous guest-token GraphQL no longer returns tweet media — yt-dlp says "No video could be found in this tweet" for tweets that extract fine with --cookies-from-browser chrome. That text wasn't in the HLS plugin's NeedsCookies patterns, so the browser-cookie retry never fired. Added (HLS plugin 1.3.0 → 1.3.1). - Variant leak (Offline-zip under the HLS badge): the same failure made GetVariantsAsync fall through to the Website fallback's variant. A FAILED lookup now offers no variants at all (plain add; the real error surfaces on download); an EMPTY lookup still falls through, keeping the GitHub/Website pairing intact. Test updated to pin the new semantics. - Windows Start-menu shortcut: winget's portable install creates no Start entry ("installed but nowhere to find") — the app now self-registers Downloader.lnk (per-user, idempotent, best-effort) on first run. Needs on-device Windows confirm. - MS Store: author-gated plan recorded (Partner Center account → MSIX). 441/441 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpEAzmp4bQ5LDSW5ucuXk7
1 parent cdb01cd commit 1bb08e6

10 files changed

Lines changed: 125 additions & 13 deletions

File tree

openspec/changes/platform-followups/design.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,15 @@
33
2. **X.com extraction**: reproduce via SiteExtractor with an x.com URL — likely yt-dlp needs cookies (x.com now requires auth for most media) or different args; if login-gated is the cause, surface the existing cookies-from-browser retry (like YouTube) and verify the plugin retries with browser cookies; bump plugin version.
44
3. **Start-menu shortcut (Windows)**: on first run (Windows only), create %APPDATA%\Microsoft\Windows\Start Menu\Programs\Downloader.lnk pointing at the running exe (PowerShell WScript.Shell one-liner, best-effort, skip if exists). Toggle-free; uninstall note documented. Testable: the .lnk path/command construction is pure.
55
4. **MS Store**: author-gated note only (Partner Center individual account ~$19 → MSIX packaging → submission); prepare when the author has the account.
6+
7+
## Findings (recorded 2026-07-17)
8+
- **x.com root cause (verified live)**: anonymous guest-token GraphQL no longer returns tweet media —
9+
yt-dlp 2026.07.04 reports "No video could be found in this tweet" for tweets that extract PERFECTLY
10+
with `--cookies-from-browser chrome` (same tweet verified both ways on this machine). That error text
11+
wasn't in the plugin's NeedsCookies patterns, so the cookie retry never fired; added (HLS plugin
12+
v1.3.1). The variant leak was the same failure: the HLS lookup threw → the aggregation fell through
13+
to the Website fallback's "Offline copy (.zip)". Failure now yields NO variants (empty still falls
14+
through, so the GitHub-releases/Website pairing keeps working).
15+
- **MS Store (author-gated)**: needs the author's Microsoft Partner Center individual account
16+
(~$19 one-time). Then: MSIX packaging (can be added to release CI) → submission. Nothing submitted;
17+
revisit when the account exists.
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Tasks — platform-followups
2-
- [ ] 1.1 Failing test: when two resolvers claim one URL, only the winning (specific) resolver's variants are returned — a fallback's "Offline copy" must not appear in the HLS list.
3-
- [ ] 1.2 Fix the variant aggregation; bump affected plugin versions. Make 1.1 pass; commit/push; CI green.
4-
- [ ] 2.1 Diagnose x.com extraction (yt-dlp args/cookies); add/adjust tests; fix + bump HLS plugin version; commit/push; CI green.
5-
- [ ] 3.1 Windows Start-menu shortcut self-registration (first run, idempotent) + pure-logic test; commit/push; CI green.
6-
- [ ] 4.1 Record the MS Store author-gated plan in this change; no submission.
2+
- [x] 1.1 Failing test: when two resolvers claim one URL, only the winning (specific) resolver's variants are returned — a fallback's "Offline copy" must not appear in the HLS list.
3+
- [x] 1.2 Fix the variant aggregation; bump affected plugin versions. Make 1.1 pass; commit/push; CI green.
4+
- [x] 2.1 Diagnose x.com extraction (yt-dlp args/cookies); add/adjust tests; fix + bump HLS plugin version; commit/push; CI green.
5+
- [x] 3.1 Windows Start-menu shortcut self-registration (first run, idempotent) + pure-logic test; commit/push; CI green.
6+
- [x] 4.1 Record the MS Store author-gated plan in this change; no submission.

src/Downloader.Desktop.Plugins/Downloader.Desktop.Plugins.Hls/Downloader.Desktop.Plugins.Hls.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<RootNamespace>Downloader.Desktop.Plugins.Hls</RootNamespace>
1919
<!-- This plugin carries its OWN version, independent of the app's VersionPrefix. Bump it only when
2020
the plugin's code changes; the catalog/update flow compares this against the running copy. -->
21-
<Version>1.3.0</Version>
21+
<Version>1.3.1</Version>
2222
</PropertyGroup>
2323

2424
<ItemGroup>

src/Downloader.Desktop.Plugins/Downloader.Desktop.Plugins.Hls/YtDlpBinary.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,12 @@ internal static bool NeedsCookies(string stderr)
228228
if (string.IsNullOrWhiteSpace(stderr)) return false;
229229
var lower = stderr.ToLowerInvariant();
230230
return lower.Contains("--cookies") || lower.Contains("sign in") || lower.Contains("log in")
231-
|| lower.Contains("login required") || lower.Contains("age");
231+
|| lower.Contains("login required") || lower.Contains("age")
232+
// x.com/Twitter: anonymous (guest-token) GraphQL no longer returns tweet media, and
233+
// yt-dlp surfaces that as "No video could be found in this tweet" instead of a
234+
// sign-in error — a logged-in browser session DOES see the media, so retry with
235+
// cookies-from-browser exactly like the explicit sign-in cases.
236+
|| lower.Contains("no video could be found in this tweet");
232237
}
233238

234239
/// <summary>Browsers to source cookies from, most common first (yt-dlp fails fast when one is absent).</summary>

src/Downloader.Desktop.Tests/Plugins/FallbackResolverTests.cs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,17 +107,21 @@ public async Task Fallback_variants_appear_when_the_specific_resolver_offers_non
107107
}
108108

109109
[Fact(Timeout = TestTimeouts.DefaultMs)]
110-
public async Task One_failing_variant_lookup_does_not_hide_the_others()
110+
public async Task A_failing_specific_lookup_offers_no_variants_instead_of_anothers()
111111
{
112+
// The x.com report: the HLS resolver claimed the link (and the badge named it) but its quality
113+
// lookup FAILED — falling through to the Website fallback showed "Offline copy (.zip)" under a
114+
// video plugin's badge. A failure must yield NO variants (plain add; the real error surfaces
115+
// when the download runs), never another plugin's options.
112116
var pm = new PluginManager();
113117
var broken = new StubResolver { ThrowOnVariants = true };
114-
var working = new StubResolver { Fallback = true, Variants = new[] { Variant("zip") } };
118+
var fallback = new StubResolver { Fallback = true, Variants = new[] { Variant("zip") } };
115119
pm.RegisterPlugin(new StubPlugin("test.broken", broken));
116-
pm.RegisterPlugin(new StubPlugin("test.working", working));
120+
pm.RegisterPlugin(new StubPlugin("test.fallback", fallback));
117121

118-
var merged = await pm.GetVariantsAsync("https://site/page", CancellationToken.None);
122+
var shown = await pm.GetVariantsAsync("https://site/page", CancellationToken.None);
119123

120-
Assert.Equal("zip", Assert.Single(merged).Id);
124+
Assert.Null(shown);
121125
}
122126

123127
[Fact(Timeout = TestTimeouts.DefaultMs)]

src/Downloader.Desktop.Tests/Plugins/Hls/SiteExtractorTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ public class YtDlpCookieRetryTests
287287
[Theory(Timeout = TestTimeouts.DefaultMs)]
288288
[InlineData("ERROR: [youtube] abc: Sign in to confirm you’re not a bot. Use --cookies-from-browser", true)]
289289
[InlineData("ERROR: This video is age-restricted; log in to watch", true)]
290+
[InlineData("ERROR: [twitter] 643211948184596480: No video could be found in this tweet", true)]
290291
[InlineData("ERROR: Unsupported URL: https://example.com", false)]
291292
[InlineData("", false)]
292293
public void NeedsCookies_detects_signin_errors(string stderr, bool expected) =>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using Downloader.Desktop.Services;
2+
using Xunit;
3+
4+
namespace Downloader.Desktop.Tests.Unit;
5+
6+
/// <summary>Guards the Windows Start-menu shortcut script (winget/portable installs create no entry —
7+
/// "installed successfully but I can't find it anywhere").</summary>
8+
public class StartMenuShortcutTests
9+
{
10+
[Fact(Timeout = TestTimeouts.DefaultMs)]
11+
public void Shortcut_script_targets_the_exe_and_saves_the_lnk()
12+
{
13+
var script = StartMenuShortcut.BuildShortcutScript(
14+
lnkPath: @"C:\Users\u\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Downloader.lnk",
15+
exePath: @"C:\Apps\Downloader\Downloader.exe");
16+
17+
Assert.Contains(@"CreateShortcut('C:\Users\u\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Downloader.lnk')", script);
18+
Assert.Contains(@"$s.TargetPath='C:\Apps\Downloader\Downloader.exe'", script);
19+
Assert.Contains(@"$s.WorkingDirectory='C:\Apps\Downloader'", script);
20+
Assert.Contains("$s.Save()", script);
21+
}
22+
}

src/Downloader.Desktop/Services/PluginManager.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,10 +212,16 @@ public async Task<IReadOnlyList<LinkVariant>> GetVariantsAsync(string url, Cance
212212
var variants = await resolver.GetVariantsAsync(url, null, cancellationToken).ConfigureAwait(false);
213213
if (variants is { Count: > 0 })
214214
return variants;
215+
// Empty = "this resolver has no choices to offer" — a later (fallback) resolver may.
215216
}
216217
catch (Exception ex) when (ex is not OperationCanceledException)
217218
{
218-
AppLog.Error($"Variant lookup failed for {url} — trying the next claiming resolver", ex);
219+
// FAILURE is different from "no choices": the badge names THIS resolver, so showing a
220+
// later plugin's variants instead would mislead (the x.com report — the HLS lookup
221+
// failed and the Website fallback's "Offline copy (.zip)" appeared under the HLS
222+
// badge). Offer nothing; the real error surfaces when the download actually runs.
223+
AppLog.Error($"Variant lookup failed for {url} — offering no variants (plain add)", ex);
224+
return null;
219225
}
220226
}
221227
return null;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using System;
2+
using System.Diagnostics;
3+
using System.IO;
4+
5+
namespace Downloader.Desktop.Services;
6+
7+
/// <summary>
8+
/// Windows: self-register a Start-menu shortcut on first run. winget's zip/portable install puts the
9+
/// exe on PATH but creates NO Start-menu entry — users reported "installed successfully but I can't
10+
/// find it anywhere". Idempotent (skips when the shortcut exists), best-effort, per-user (no admin).
11+
/// Removed by deleting %APPDATA%\Microsoft\Windows\Start Menu\Programs\Downloader.lnk.
12+
/// </summary>
13+
public static class StartMenuShortcut
14+
{
15+
public static void EnsureOnWindows()
16+
{
17+
if (!OperatingSystem.IsWindows())
18+
return;
19+
try
20+
{
21+
var exe = Environment.ProcessPath;
22+
if (string.IsNullOrWhiteSpace(exe))
23+
return;
24+
var programs = Path.Combine(
25+
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
26+
"Microsoft", "Windows", "Start Menu", "Programs");
27+
var lnk = Path.Combine(programs, "Downloader.lnk");
28+
if (File.Exists(lnk))
29+
return;
30+
Directory.CreateDirectory(programs);
31+
32+
var script = BuildShortcutScript(lnk, exe);
33+
Process.Start(new ProcessStartInfo("powershell",
34+
$"-NoProfile -Command \"{script}\"")
35+
{
36+
UseShellExecute = false,
37+
CreateNoWindow = true
38+
});
39+
}
40+
catch
41+
{
42+
// a missing shortcut must never break startup
43+
}
44+
}
45+
46+
/// <summary>The PowerShell that creates the .lnk (WScript.Shell COM). Pure — unit-tested.
47+
/// The working dir is split on '\' explicitly so the helper behaves the same when the tests run
48+
/// it on Linux (Path.GetDirectoryName doesn't parse Windows paths there).</summary>
49+
internal static string BuildShortcutScript(string lnkPath, string exePath)
50+
{
51+
var cut = exePath.LastIndexOf('\\');
52+
var workDir = cut > 0 ? exePath[..cut] : exePath;
53+
return "$s=(New-Object -ComObject WScript.Shell).CreateShortcut('" + lnkPath + "'); " +
54+
"$s.TargetPath='" + exePath + "'; " +
55+
"$s.WorkingDirectory='" + workDir + "'; " +
56+
"$s.Description='Downloader — fast multi-connection download manager'; " +
57+
"$s.Save()";
58+
}
59+
}

src/Downloader.Desktop/ViewModels/MainViewModel.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ private void SetupAppShell()
262262
// Keep the OS autostart entry in sync with the setting on every launch.
263263
StartupService.Apply(_config.Settings.RunAtStartup);
264264

265+
// winget/portable installs create no Start-menu entry — self-register one (Windows, first run).
266+
StartMenuShortcut.EnsureOnWindows();
267+
265268
// Local API + browser integration: extension links open the Add dialog pre-filled; the
266269
// /api routes act on the manager directly (silent adds from scripts and the CLI).
267270
LocalApiService.OnUrlCaptured = CaptureUrl;

0 commit comments

Comments
 (0)