Skip to content

Commit f4bbeac

Browse files
ColinGPT9claude
andcommitted
Microsoft Store copies say when the Store has a newer version
A Store copy said only "the Store keeps Clips Kitty up to date for you", checked nothing and showed no version, so someone the Store had not updated yet had no way to know they were behind. Now Check for updates, and a quiet check at start, ask the Store's public catalog which version it offers. When that is newer than the one running, a bar at the top and Settings say which version is in the Store and which this copy is, with an Open the Microsoft Store button. Skip this version hides the bar. When the check can't be made, Settings says so and still offers the button. The app still downloads and installs nothing in a Store copy: the Store does both. The request carries the app's Store ID and nothing about the person or their PC; the privacy page says so. The Store's catalog is asked, not the Hugging Face feed the installer version reads, because a release is on Hugging Face before Store certification finishes. It reaches people from the next Store package on. Not seen in a Store-signed build yet, and the screens have not been looked at by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d46d47a commit f4bbeac

12 files changed

Lines changed: 324 additions & 19 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,14 @@ were often broken in a way that only showed up on somebody else's machine.
162162

163163
### Changed
164164

165+
- **The Microsoft Store version tells you when the Store has a newer one.** It used to
166+
say only that the Store keeps it up to date, even when the Store hadn't got to it yet.
167+
Now **Check for updates**, and a quiet check at start, ask the Store which version it
168+
offers. When that is newer than yours, a bar at the top and Settings say which version
169+
is there and which you have, with an **Open the Microsoft Store** button; press Update
170+
on that page. The app still downloads and installs nothing itself in a Store copy, and
171+
the request carries only the app's Store ID.
172+
165173
- **A watched channel's re-sends keep its first choice of clips.** Retry failed and the
166174
re-send of posts a platform rejected now send only clips the video's first publish
167175
chose, even if processing the video again changed the scores or made new clips in

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,8 @@ Running from source needs the things the installer would otherwise bundle for yo
303303
**The easiest way is the [Microsoft Store](https://apps.microsoft.com/detail/9NB6XT7DSQZZ).**
304304
One click, it updates itself, and Windows raises no security prompt. Microsoft
305305
certified and signed the package, so the SmartScreen warning described below
306-
does not appear at all.
306+
does not appear at all. The Store installs new versions when it gets to them; to have
307+
one at once, open Clips Kitty's page in the Microsoft Store and press **Update**.
307308

308309
**Or grab the installer.** The latest **Web Setup** from
309310
[Releases](../../releases) and run it. It carries the app, the Python engine, every

‎docs/MSSTORE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ reality:
386386

387387
| | Standalone | Store |
388388
|---|---|---|
389-
| Updates | electron-updater, Hugging Face feed | the Store |
389+
| Updates | electron-updater, Hugging Face feed | the Store installs them; the app asks the Store's catalog which version it offers and, when this copy is behind, says so with a button to the Store page (`ui/src/main/storeVersion.ts`) |
390390
| Donate button | PayPal in a locked-down in-app window | PayPal in the system browser |
391391

392392
Everything else (the engine, the models, the pipeline, the data directory) is

‎site/privacy.html‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ <h1>Clips Kitty does not collect anything about you</h1>
6262
service you have connected. This page explains exactly what is stored, what can
6363
leave your machine, and what triggers it.
6464
</p>
65-
<p><strong>Last updated:</strong> 7 October 2026</p>
65+
<p><strong>Last updated:</strong> 8 October 2026</p>
6666
</div>
6767
</div>
6868

@@ -112,7 +112,7 @@ <h3>Because you asked it to</h3>
112112

113113
<h3>On its own, and you can switch it off</h3>
114114
<ul>
115-
<li><strong>Checking for updates.</strong> The desktop app asks a static file on Hugging Face whether a newer version exists. This is a plain file fetch with no identifier attached, and it can be turned off in Settings. Copies installed from the Microsoft Store do not do this at all, because the Store handles updates.</li>
115+
<li><strong>Checking for updates.</strong> The desktop app asks a static file on Hugging Face whether a newer version exists. This is a plain file fetch with no identifier attached, and it can be turned off in Settings. Copies installed from the Microsoft Store ask Microsoft's Store catalog instead which version the Store offers, when the app starts and when you press Check for updates: one plain request that carries the app's Store ID and nothing about you or your computer. The Store itself downloads and installs the update.</li>
116116
<li><strong>Checking for new pipelines.</strong> When you open the Marketplace, or start Clips Kitty with a pipeline installed, and the app's copy of the list of pipelines is more than a day old, the app downloads Clips Kitty's current list: one file from raw.githubusercontent.com, where GitHub serves the files of Clips Kitty's public code. It brings new pipelines and warnings about pipelines found to be harmful. This is a plain file fetch with no identifier attached; nothing about what you browse, search or have installed is sent. It can be switched off at the bottom of the Marketplace's Browse tab, and the Check for new pipelines button there fetches the list when you press it.</li>
117117
</ul>
118118

‎tests/test_ui_store_update.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""A Microsoft Store copy says when the Store has a newer version.
2+
3+
The Store updates a Store copy, on its own schedule; the app never downloads
4+
or installs anything there. What it does is ask the Store's public catalog
5+
which version is offered and compare that with the one running, so somebody
6+
whose Store has not updated yet is told, with a button to the Store page.
7+
8+
The two decisions in that (which version the catalog's answer names, and
9+
whether one version is newer than another) are in ui/src/main/storeVersion.ts,
10+
which has no Electron in it and is run here under Node. The rest is checked as
11+
text: the ids match the ones the package and the README use, and a Store copy
12+
still never reaches for the installer's updater.
13+
"""
14+
15+
import json
16+
import re
17+
import shutil
18+
import subprocess
19+
from pathlib import Path
20+
21+
import pytest
22+
23+
ROOT = Path(__file__).resolve().parent.parent
24+
MAIN = ROOT / "ui" / "src" / "main"
25+
26+
27+
def _node() -> str:
28+
node = shutil.which("node")
29+
if node is None:
30+
pytest.skip("Node isn't available")
31+
return node
32+
33+
34+
def _run(tmp_path, cases: list, body: str) -> list:
35+
"""Run `body` (JavaScript: `m` is the module, `c` one case) over the cases."""
36+
(tmp_path / "storeVersion.ts").write_text((MAIN / "storeVersion.ts").read_text(encoding="utf-8"), encoding="utf-8")
37+
(tmp_path / "cases.json").write_text(json.dumps(cases), encoding="utf-8")
38+
script = (
39+
f"const m = await import({json.dumps((tmp_path / 'storeVersion.ts').as_uri())});"
40+
"const fs = await import('node:fs');"
41+
f"const cases = JSON.parse(fs.readFileSync({json.dumps(str(tmp_path / 'cases.json'))}, 'utf8'));"
42+
f"console.log(JSON.stringify(cases.map((c) => {{ {body} }})));"
43+
)
44+
r = subprocess.run([_node(), "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e", script],
45+
capture_output=True, text=True, encoding="utf-8", timeout=120)
46+
if r.returncode != 0 and "strip-types" in r.stderr:
47+
pytest.skip("this Node can't run TypeScript directly")
48+
assert r.returncode == 0, r.stderr
49+
return json.loads(r.stdout)
50+
51+
52+
def _catalog(*full_names):
53+
"""The catalog's answer, cut down to the part that names packages."""
54+
return {"Product": {"ProductId": "9NB6XT7DSQZZ", "DisplaySkuAvailabilities": [
55+
{"Sku": {"Properties": {"Packages": [{"PackageFullName": n, "MaxDownloadSizeInBytes": 1}]}}}
56+
for n in full_names]}}
57+
58+
59+
def test_the_version_the_store_offers_is_read_from_its_packages(tmp_path):
60+
ours = "ClipsStudio.ClipsStudio_{}_x64__315g1r74a6w58"
61+
cases = [
62+
_catalog(ours.format("2.0.0.0")),
63+
_catalog(ours.format("2.0.0.0"), ours.format("2.0.0.0")), # one per SKU, as the Store answers today
64+
_catalog(ours.format("1.2.0.0"), ours.format("2.1.0.0"), ours.format("2.0.0.0")), # the highest
65+
_catalog(ours.format("2.9.0.0"), ours.format("2.10.0.0")), # by number, not by letter
66+
_catalog("SomeoneElse.Other_9.0.0.0_x64__abcdefghjkmnp", ours.format("2.0.0.0")), # only this app's
67+
_catalog("SomeoneElse.Other_9.0.0.0_x64__abcdefghjkmnp"),
68+
_catalog(ours.format("2.0.0")), # not a package version
69+
_catalog(),
70+
{"Product": None},
71+
{"code": "NotFound"},
72+
None,
73+
"not json at all",
74+
[],
75+
]
76+
got = _run(tmp_path, cases, "return m.storeVersionOf(c)")
77+
assert got == ["2.0.0", "2.0.0", "2.1.0", "2.10.0", "2.0.0", None, None, None, None, None, None, None, None]
78+
79+
80+
def test_newer_means_a_later_version_and_nothing_else(tmp_path):
81+
cases = [
82+
("2.0.0", "1.1.4", True), ("2.0.0", "1.2.0", True), ("2.0.1", "2.0.0", True), ("2.10.0", "2.9.0", True),
83+
("2.0.0", "2.0.0", False), ("2.0", "2.0.0", False), ("2.0.0", "2.0", False),
84+
("1.2.0", "2.0.0", False), # the Store behind this copy (a test build): no "update"
85+
("2.1.0-beta.1", "2.0.0", False), ("2.1.0", "2.1.0-beta.1", False), ("", "2.0.0", False),
86+
("latest", "2.0.0", False),
87+
]
88+
got = _run(tmp_path, [list(c[:2]) for c in cases], "return m.isNewer(c[0], c[1])")
89+
assert got == [c[2] for c in cases]
90+
91+
92+
def test_the_ids_are_the_ones_the_package_and_the_readme_use():
93+
source = (MAIN / "storeVersion.ts").read_text(encoding="utf-8")
94+
product = re.search(r"STORE_PRODUCT_ID = '([A-Z0-9]+)'", source).group(1)
95+
identity = re.search(r"STORE_IDENTITY = '([\w.]+)'", source).group(1)
96+
assert f"apps.microsoft.com/detail/{product}" in (ROOT / "README.md").read_text(encoding="utf-8")
97+
builder = (ROOT / "ui" / "electron-builder.yml").read_text(encoding="utf-8")
98+
assert re.search(rf"^\s*identityName: {re.escape(identity)}\s*$", builder, re.M)
99+
# The Store's own page and catalog, by that id, and nowhere else.
100+
assert "ms-windows-store://pdp/?ProductId=${STORE_PRODUCT_ID}" in source
101+
assert "https://displaycatalog.mp.microsoft.com/v7.0/products/${STORE_PRODUCT_ID}?" in source
102+
103+
104+
def test_a_store_copy_asks_the_store_and_never_runs_the_installers_updater():
105+
"""Everything a Store copy does about updates is before the `return` that
106+
ends its branch, and none of it touches electron-updater."""
107+
source = (MAIN / "updater.ts").read_text(encoding="utf-8")
108+
start = source.index("if (isMicrosoftStore()) {")
109+
branch = source[start:source.index("\n return\n }\n", start)]
110+
assert "autoUpdater" not in branch
111+
assert "net.fetch(STORE_CATALOG" in branch and "storeVersionOf(" in branch and "isNewer(" in branch
112+
for handler in ("update:check", "update:download", "update:install", "update:skip", "update:prefs"):
113+
assert f"ipcMain.handle('{handler}'" in branch, handler
114+
# Download and install stay refused there: the Store does both.
115+
assert "ipcMain.handle('update:download', async () => ({ ok: false }))" in branch
116+
assert "ipcMain.handle('update:install', () => ({ ok: false }))" in branch
117+
# The button opens the Store's page, a fixed address the page can't choose.
118+
assert "shell.openExternal(STORE_PAGE)" in source
119+
120+
121+
def test_the_screens_say_which_version_and_offer_the_store():
122+
renderer = ROOT / "ui" / "src" / "renderer" / "src"
123+
settings = (renderer / "pages" / "Settings.tsx").read_text(encoding="utf-8")
124+
banner = (renderer / "components" / "UpdateBanner.tsx").read_text(encoding="utf-8")
125+
assert "Version ${state.version} is in the Microsoft Store. This copy is ${state.current}." in settings
126+
assert "You're on the latest version (${state.current})." in settings
127+
assert "Couldn't check the Microsoft Store just now." in settings
128+
assert "window.studio.update.openStore()" in settings and "window.studio.update.openStore()" in banner
129+
# The bar shows only when the Store is ahead and that version wasn't skipped.
130+
assert "if (!s.behind || s.skipped) return null" in banner
131+
assert "openStore: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('update:openStore')" in (
132+
ROOT / "ui" / "src" / "preload" / "index.ts").read_text(encoding="utf-8")

‎ui/src/main/storeVersion.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Which version the Microsoft Store offers, read from its public catalog.
2+
//
3+
// A Store copy is updated by the Store, never by this app (distribution.ts).
4+
// But the Store updates on its own schedule, and that can be paused, switched
5+
// off or stuck behind a download of several gigabytes. A copy that only says
6+
// "the Store keeps this up to date" leaves somebody three versions behind with
7+
// no way to know. So the app asks what the Store has and says when it is newer.
8+
//
9+
// The Store's own catalog, not the Hugging Face feed the standalone build
10+
// reads: a release is on Hugging Face a day or more before certification
11+
// finishes, and "a new version is out" must never send people to a Store that
12+
// does not have it yet.
13+
//
14+
// No Electron in here, so it runs under Node in tests/test_ui_store_update.py.
15+
16+
/** Clips Kitty in the Microsoft Store (the id in the README's Store link). */
17+
export const STORE_PRODUCT_ID = '9NB6XT7DSQZZ'
18+
19+
/** The package's name (appx.identityName in electron-builder.yml). */
20+
export const STORE_IDENTITY = 'ClipsStudio.ClipsStudio'
21+
22+
/** Clips Kitty's page in the Store app, where Update is pressed. */
23+
export const STORE_PAGE = `ms-windows-store://pdp/?ProductId=${STORE_PRODUCT_ID}`
24+
25+
/** One plain request, carrying the product id and nothing about this PC. */
26+
export const STORE_CATALOG = `https://displaycatalog.mp.microsoft.com/v7.0/products/${STORE_PRODUCT_ID}?market=US&languages=en-US`
27+
28+
function parts(version: string): number[] | null {
29+
if (!/^\d+(\.\d+)*$/.test(version)) return null
30+
return version.split('.').map(Number)
31+
}
32+
33+
/** True when `a` is a later version than `b`. A part left out counts as 0,
34+
* so 2.0 and 2.0.0 are the same version. Anything that is not numbers and
35+
* dots (a pre-release name) is never newer: saying nothing beats saying
36+
* "update" wrongly. */
37+
export function isNewer(a: string, b: string): boolean {
38+
const x = parts(a)
39+
const y = parts(b)
40+
if (!x || !y) return false
41+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
42+
const d = (x[i] ?? 0) - (y[i] ?? 0)
43+
if (d !== 0) return d > 0
44+
}
45+
return false
46+
}
47+
48+
/** The app version the Store offers, as "2.0.0": the highest among the
49+
* catalog's packages of this app. A package is 2.0.0.0 (the fourth part is
50+
* the Store's own and always 0), and the app calls that 2.0.0. null when the
51+
* answer has no such package, whatever shape it came in. */
52+
export function storeVersionOf(catalog: unknown, identity: string = STORE_IDENTITY): string | null {
53+
const names: string[] = []
54+
const walk = (node: unknown, depth: number): void => {
55+
if (depth > 12 || node === null || typeof node !== 'object') return
56+
if (Array.isArray(node)) {
57+
for (const item of node) walk(item, depth + 1)
58+
return
59+
}
60+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
61+
if (key === 'PackageFullName' && typeof value === 'string') names.push(value)
62+
else walk(value, depth + 1)
63+
}
64+
}
65+
walk(catalog, 0)
66+
67+
let best: string | null = null
68+
for (const name of names) {
69+
// ClipsStudio.ClipsStudio_2.0.0.0_x64__315g1r74a6w58
70+
const [who, version] = name.split('_')
71+
if (who !== identity || !version || !/^\d+\.\d+\.\d+\.\d+$/.test(version)) continue
72+
const app = version.split('.').slice(0, 3).join('.')
73+
if (best === null || isNewer(app, best)) best = app
74+
}
75+
return best
76+
}

‎ui/src/main/updater.ts‎

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313
// payload is too big for a release asset, so it lives on Hugging Face. See the
1414
// publish block in electron-builder.yml.
1515

16-
import { app, ipcMain, type BrowserWindow } from 'electron'
16+
import { app, ipcMain, net, shell, type BrowserWindow } from 'electron'
1717
import { readFileSync, statSync, writeFileSync } from 'node:fs'
1818
import { join } from 'node:path'
1919
import { autoUpdater, type UpdateInfo } from 'electron-updater'
2020
import { isMicrosoftStore } from './distribution'
21+
import { STORE_CATALOG, STORE_PAGE, isNewer, storeVersionOf } from './storeVersion'
2122

2223
/** Which releases this install is offered. Alpha sees everything, stable
2324
* only sees finished releases. Read from the same file the renderer writes,
@@ -156,24 +157,64 @@ function stopPackageWatch(): void {
156157
export function setupUpdater(win: BrowserWindow): void {
157158
mainWindow = win
158159

160+
// Clips Kitty's page in the Store app. Only a Store copy shows the button.
161+
ipcMain.handle('update:openStore', async () => {
162+
await shell.openExternal(STORE_PAGE)
163+
return { ok: true }
164+
})
165+
159166
// A Store copy is updated by the Store. electron-updater is not merely
160167
// unnecessary there, it is wrong: it would fetch the NSIS installer and try
161168
// to run it over a package Windows itself manages. So it is never wired up
162-
// at all — no listeners, no startup check — and the handlers below answer
163-
// honestly instead of pretending to check.
169+
// at all, and nothing is ever downloaded or installed from here.
170+
//
171+
// What a Store copy does do is ask the Store's catalog which version it
172+
// offers (storeVersion.ts) and say so when that is newer than this one: the
173+
// Store updates when it chooses to, and "the Store keeps this up to date"
174+
// told somebody three versions behind nothing at all.
164175
if (isMicrosoftStore()) {
165-
const storeState = (): void => send('update:state', { state: 'store' })
166-
ipcMain.handle('update:check', async () => {
167-
storeState()
168-
return { ok: false, reason: 'store' }
169-
})
176+
let last: Record<string, unknown> = { state: 'store', current: app.getVersion() }
177+
const tell = (): void =>
178+
send('update:state', { ...last, skipped: last.behind === true && loadPrefs().skipped === last.version })
179+
180+
const storeCheck = async (): Promise<{ ok: boolean; reason?: string }> => {
181+
send('update:state', { state: 'checking' })
182+
const current = app.getVersion()
183+
let offered: string | null = null
184+
try {
185+
const r = await net.fetch(STORE_CATALOG, { signal: AbortSignal.timeout(15000) })
186+
if (r.ok) offered = storeVersionOf(await r.json())
187+
} catch {
188+
// Offline, or the catalog answered differently: `offered` stays null
189+
// and the screen says the check didn't work.
190+
}
191+
last = {
192+
state: 'store',
193+
current,
194+
version: offered ?? undefined,
195+
behind: offered !== null && isNewer(offered, current)
196+
}
197+
tell()
198+
return { ok: offered !== null, reason: 'store' }
199+
}
200+
201+
ipcMain.handle('update:check', storeCheck)
170202
ipcMain.handle('update:download', async () => ({ ok: false }))
171203
ipcMain.handle('update:install', () => ({ ok: false }))
172-
ipcMain.handle('update:skip', () => ({ ok: false }))
204+
// Hides the bar for that version; Settings still says it is there.
205+
ipcMain.handle('update:skip', (_e, version: unknown) => {
206+
if (typeof version !== 'string') return { ok: false }
207+
savePrefs({ ...loadPrefs(), skipped: version })
208+
tell()
209+
return { ok: true }
210+
})
173211
// Channel is still readable and writable so the screen renders, but it
174212
// decides nothing here.
175213
ipcMain.handle('update:prefs', () => loadPrefs())
176-
storeState()
214+
// One quiet check once the app is responsive, as the standalone copy does.
215+
setTimeout(() => {
216+
storeCheck().catch(() => undefined)
217+
}, 8000)
177218
return
178219
}
179220

‎ui/src/preload/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ contextBridge.exposeInMainWorld('studio', {
6161
ipcRenderer.invoke('update:skip', version),
6262
prefs: (patch?: { channel?: string }): Promise<{ channel: string; skipped?: string }> =>
6363
ipcRenderer.invoke('update:prefs', patch),
64+
/** Clips Kitty's page in the Microsoft Store app (a Store copy's update button). */
65+
openStore: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('update:openStore'),
6466
/** Subscribe to update state. Returns an unsubscribe function. */
6567
onState: (fn: (s: Record<string, unknown>) => void): (() => void) => {
6668
const handler = (_e: unknown, s: Record<string, unknown>): void => fn(s)

0 commit comments

Comments
 (0)