mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-27 01:02:16 +01:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33838da03a | ||
|
|
2970f5da61 | ||
|
|
5741ae6243 | ||
|
|
6cfaabbe8f | ||
|
|
0905842ded | ||
|
|
f4cc9ce0a1 | ||
|
|
617661b7a2 | ||
|
|
38db6a2846 | ||
|
|
134ceea2b7 | ||
|
|
1824b54140 |
No files matched your search
@@ -0,0 +1,59 @@
|
||||
# v0.4.49 tagged release verification, 26 October 2026
|
||||
|
||||
The annotated `v0.4.49` tag dereferences to merged main commit
|
||||
`38db6a284696c3bb86bf97f6b853af7e97681701`. This is the same tree as
|
||||
the reviewed [candidate PR #348](https://github.com/saphid/frame-control/pull/348).
|
||||
Its [main checks run](https://github.com/saphid/frame-control/actions/runs/40417275314)
|
||||
passed all seven jobs, including e2e, iOS, macOS, Windows and Linux.
|
||||
The [native Decky 3.3.0 run](https://github.com/saphid/frame-control/actions/runs/40417275256)
|
||||
passed on AMD64 and ARM64. The regression gate includes an expected failure
|
||||
for v0.4.48's bad legacy calling convention.
|
||||
|
||||
The exact-tag [release workflow](https://github.com/saphid/frame-control/actions/runs/40418177330)
|
||||
passed all six jobs on its first attempt: draft, Linux, Windows, Apple Silicon
|
||||
Mac, Intel Mac, and checksums. No tagged build failed, was cancelled, or was
|
||||
skipped. The draft contained exactly ten installers and `SHA256SUMS` before
|
||||
publication. Every asset had a GitHub SHA-256 digest. A fresh download of
|
||||
`SHA256SUMS` had SHA-256
|
||||
`8a04f81f3b1efea9903e9acbba2c61b67f275869c160e0c6fa1d388a4803b42a`;
|
||||
it matched a list regenerated from the ten GitHub installer digests byte for
|
||||
byte. All ten installers were independently downloaded and passed
|
||||
`sha256sum -c SHA256SUMS`.
|
||||
|
||||
## Independent package checks
|
||||
|
||||
| Package | Check |
|
||||
| --- | --- |
|
||||
| Linux x86_64 AppImage | SHA-256 `0ef192e6f13aed39469392c22b971b311d911a0bebae16cbbabbfa729b95ced0`; isolated Xvfb launch returned HTTP 200 with `FrameControl/1`, then its server stopped. The first local launch attempt hit the workspace sandbox's `Operation not permitted` restriction; the same downloaded bytes passed outside that sandbox. Tagged Linux CI also launched the AppImage. |
|
||||
| Linux arm64 DEB | SHA-256 `a8e448b8b8f10b2813546cb9d8fea6bbe424e14dc3a739df86daa69bfd4023c1`; declares version 0.4.49 and arm64. |
|
||||
| Linux amd64 DEB | SHA-256 `10bb554cc761c61c99d059ae3601f4889187d7f18bdc8f9eb3cf768822b5765d`; declares version 0.4.49 and amd64. |
|
||||
| Mac arm64 ZIP | SHA-256 `c43c1017b3d244fcf59ab8f5bd35dbad17af4562b2aca3baf65908814f2ae4c3`; ZIP CRC, bundle version 0.4.49 and main Mach-O arm64 CPU passed. |
|
||||
| Mac x64 ZIP | SHA-256 `0dd838a1322107928893ff5efce817719867a5db1af2fa09124b6bb03018a19d`; ZIP CRC, bundle version 0.4.49 and main Mach-O x86_64 CPU passed. |
|
||||
| Windows x64 Setup | SHA-256 `386d99fd15314e1caef577f7cdf21e9e9f572d1c0dbaa65c369e5a5b0c047d31`; downloaded file is a PE/Nullsoft installer. Tagged Windows CI installed it and checked its packaged resources and Python libraries. |
|
||||
| Windows x64 ZIP | SHA-256 `2c201a8d1115dd1a07ba7b9293f7e05c319bd802f574a4b2f9565268a7f5cf8b`; ZIP CRC passed. |
|
||||
|
||||
The remaining three packages (both Mac DMGs and the ARM64 AppImage) also
|
||||
matched `SHA256SUMS`; tagged CI built and checked their platform bundles.
|
||||
Physical launch of these exact Mac and Windows installers was not performed.
|
||||
|
||||
## Publication and real Frame scope
|
||||
|
||||
The [candidate evidence](v0449-candidate-2026-10-26.md) records a unique-lock
|
||||
real Frame smoke on the version-bumped source: **12 passed, one optional
|
||||
x86_64 launch skipped, zero failed**, followed by an independent locked audit
|
||||
with no test-title folders or processes. The optional runtime was absent; no
|
||||
x86_64 launch is claimed. SteamVR stayed in standby, no wearer was confirmed,
|
||||
and neither a worn test nor physical controller check was run.
|
||||
|
||||
`scripts/publish-release.sh --dry-run v0.4.49` passed the exact main, native
|
||||
Decky and tagged release job gates, ten installer hashes, regenerated
|
||||
`SHA256SUMS`, draft notes and update manifest. The real publisher repeated
|
||||
those gates, attached `update.json`, and published
|
||||
[v0.4.49](https://github.com/saphid/frame-control/releases/tag/v0.4.49) at
|
||||
**23:25:45 UTC**. GitHub's latest-release API returned `draft=false`,
|
||||
`prerelease=false`, ten installers, `SHA256SUMS` and `update.json`. A fresh
|
||||
public latest-download of `update.json` reported version 0.4.49, the correct
|
||||
page, ten hashed assets, the v0.4.48 repair note and the worn-VR limit.
|
||||
The v0.4.48 release remains a withdrawn draft; its notice points to v0.4.49.
|
||||
Existing v0.4.48 installations do not auto-downgrade, and affected installed
|
||||
Music Control plugins need managed Update or Remove and reinstall.
|
||||
@@ -63,7 +63,7 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
|
||||
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test |
|
||||
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
|
||||
| Scrollable panel content | Visible Scroll plugin up/down buttons move the last region reached by a trusted pointer, or the document if that region disappears. Plugins may still use ordinary CSS overflow containers. | Parent/iframe message tests; physical controller scroll remains unverified |
|
||||
| Scrollable panel content | Visible Scroll plugin up/down buttons move the last region reached by a trusted pointer, or the document if that region disappears. Hold either button to repeat; keyboard activation still moves one step. Plugins may use ordinary CSS overflow containers. | Parent/iframe message tests and a real Frame synthetic OpenVR pointer hold; physical controller scroll and hold remain unverified |
|
||||
| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers, progress bars | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium |
|
||||
| `SteamUIStore` / `DFL.Router.RunningApps`, `MainRunningApp` | Bounded detached Steam CEF reads while the panel is open; refresh about every two seconds; no MobX observation. Failed reads clear stale context. | Locked real Frame empty-list reads; nonempty local Chromium fixtures. Real running-game selection unverified. |
|
||||
| `GameSessions.RegisterForAppLifetimeNotifications` | Actual Steam callbacks, bounded queue and disposable `.unregister()` handle; no events inferred from snapshot changes; lease cleanup after lost panels | Real Frame registration/poll/close; launch/exit, retry, expiry and duplicate tests use explicit fixtures. Real game transitions unverified. |
|
||||
@@ -74,10 +74,13 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| API v1/v2 `addEventListener` / `removeEventListener` | Plugin-scoped real backend events while panel is open; bounded queue, about one-second polling, subscription expiry | Real ARM64 Simple Timer countdown, start/cancel and completion toast via local Chromium; scope/bounds/reconnect tests. No closed-panel notifications. |
|
||||
| `toaster.toast` | Visible toast inside the plugin panel; plain text or React nodes for title, body, icon, logo and subtext; `dismiss` and optional `onClick` | Host unit test; no Steam system notification or worn VR check |
|
||||
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
|
||||
| `SteamClient.Storage.GetJSON` / `SetObject` | Uses the same bounded per-plugin cache; a missing key rejects with `Not found`. It cannot read Steam account or global storage. | Host contract tests and local Chromium settings views; headset persistence after reopen remains unverified. |
|
||||
| IndexedDB / localForage | IndexedDB is deliberately absent inside the opaque iframe so feature detection chooses the bounded localStorage driver. Plugins requiring IndexedDB directly remain unsupported. | HLTB official frontend in local Chromium: its previous `IDBFactory.open` SecurityError disappeared. No live HLTB data request was run. |
|
||||
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values | Host unit test; VR navigation unverified |
|
||||
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
|
||||
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
|
||||
| `findModule`, `findModuleChild`, `findModuleExport`, class lookup and module mapping | Searches a finite registry of Framey controls and CSS maps. Unknown Steam modules return `undefined` or an empty mapping. The optional UI-audio export cannot play sound. | Host contract tests; pinned TabMaster 2.15.1 QAM and documentation render in local Chromium. Steam library editing and worn VR remain unverified. |
|
||||
| `Navigation.CloseSideMenus` | Returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 shallow settings view in local host test. Live HLTB search remains unverified. |
|
||||
| `Focusable.onActivate`, `showModal`, `ConfirmModal` | Pointer and Enter/Space activation, disabled state, and local confirmation dialogs with cancel/close and nested-dialog focus | Host tests and official AlarMe 1.6.3 timer/preset controls with a real ARM64 backend in local Chromium. Sound, suspend and worn VR remain unverified. |
|
||||
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
|
||||
| `fetchNoCors`, file picker, root calls | Unavailable | Not supported by Framey |
|
||||
|
||||
@@ -88,6 +91,17 @@ Optional route segments must be at the end. Framey accepts local path segments
|
||||
only; query strings, external URLs, Steam library routes and duplicate parameter
|
||||
names are rejected. Removing an active route returns to the plugin panel.
|
||||
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
|
||||
Treat module lookup results as Framey-local exports. Do not depend on Steam
|
||||
webpack IDs, function-source signatures, or Steam-only CSS class names; a
|
||||
lookup that works in the Steam Deck QAM can still return `undefined` here.
|
||||
Use a local control or a visible unsupported message when it does. For a
|
||||
confirmation, call `showModal` with a `ConfirmModal` and keep any irreversible
|
||||
operation behind its OK action. `Focusable.onActivate` accepts pointer and
|
||||
Enter/Space input in Framey, but only a worn-headset test can establish how a
|
||||
physical controller reaches it. See the
|
||||
[module lookup evidence](evidence/framey-module-lookup-2026-10-26.md) and
|
||||
[dialog control evidence](evidence/framey-dialog-controls-2026-10-26.md) for
|
||||
the exact tested official versions.
|
||||
An API v1 manifest does not guarantee a working Framey panel. Test the exact
|
||||
store archive and do not assume SteamClient app details, library patches, or
|
||||
destructive actions work in VR. The
|
||||
|
||||
@@ -224,6 +224,9 @@ chunks from two installed versions.
|
||||
The headset also includes the panel's store SHA-256 receipt with each backend
|
||||
call, Steam read, and event poll. It rejects a request if the installed receipt
|
||||
changed before the next status refresh; reopen the panel to use the new version.
|
||||
Panel-ready, error, and trusted-pointer reports also carry the current panel
|
||||
session. A delayed report from a closed panel cannot change the new panel's
|
||||
status or count as input to that panel.
|
||||
|
||||
## For plugin authors
|
||||
|
||||
|
||||
@@ -61,8 +61,10 @@ No wheel event or offset change means this synthetic injection path remains
|
||||
unverified; it does not establish whether a physical controller can scroll.
|
||||
The exercise then clicks the visible **Scroll plugin down/up** buttons through
|
||||
the same native OpenVR pointer transport and requires the test region to move
|
||||
down and return to the top. This proves the button path only; it does not
|
||||
identify a physical controller or repair native wheel delivery.
|
||||
down and return to the top. It also holds each button through that transport,
|
||||
requires Down to move farther than a single click, and requires Up to return
|
||||
to the top. These prove the synthetic button paths only; they do not identify
|
||||
a physical controller or repair native wheel delivery.
|
||||
Temporary controls exercise the shipped
|
||||
`DFL.SliderField` and `DFL.TextField`; their values remain UI-only. Text uses
|
||||
CDP `Input.insertText` after OpenVR pointer focus. This is synthetic input,
|
||||
@@ -119,7 +121,9 @@ the same-plugin navigation regression without a headset.
|
||||
3. **1:15–2:15:** Use the controller to open Settings, return to Panel, invoke
|
||||
**Read saved setting**, close/reopen the plugin, and confirm the value
|
||||
remains. Try controller scroll, a slider and a harmless text field in an
|
||||
installed compatible plugin; check SteamVR's actual keyboard and Back.
|
||||
installed compatible plugin. Hold **Scroll plugin down** and then **Scroll
|
||||
plugin up** with the controller; record continuous movement separately from
|
||||
native thumbstick or wheel scrolling. Check SteamVR's actual keyboard and Back.
|
||||
4. **2:15–3:00:** Restore the original sample setting. Switch away and back,
|
||||
close/reopen the Frame Control panel, and confirm controller input still
|
||||
works. Record comfort/legibility, controller model, any haptic behavior,
|
||||
|
||||
@@ -71,13 +71,19 @@ def exercise(profile, plugin_id, folder):
|
||||
if not hit:
|
||||
raise RuntimeError('Native click hit another element: ' + str(panel.eval('({tag:__vrLastTarget?.tagName,text:__vrLastTarget?.textContent?.slice(0,100)})')))
|
||||
|
||||
def click_parent(selector):
|
||||
def parent_point(selector):
|
||||
parent.eval(f'document.querySelector({json.dumps(selector)}).scrollIntoView({{block:"center"}})')
|
||||
time.sleep(.3)
|
||||
pos = parent.eval(f'''(()=>{{let r=document.querySelector({json.dumps(selector)}).getBoundingClientRect();
|
||||
return {{x:r.x+r.width/2,y:r.y+r.height/2,w:innerWidth,h:innerHeight}}}})()''')
|
||||
sx, sy = vr.snapshot()['input']['scale']
|
||||
x, y = pos['x'] * sx / pos['w'], sy - pos['y'] * sy / pos['h']
|
||||
if not 0 <= x <= sx or not 0 <= y <= sy:
|
||||
raise RuntimeError('Parent control is outside the visible viewport')
|
||||
return x, y
|
||||
|
||||
def click_parent(selector):
|
||||
x, y = parent_point(selector)
|
||||
count = parent.eval('window.__vrParentClickCount')
|
||||
vr.post_mouse(300, x, y)
|
||||
vr.post_mouse(301, x, y, 1)
|
||||
@@ -86,6 +92,21 @@ def exercise(profile, plugin_id, folder):
|
||||
if not parent.eval(f'!!window.__vrParentLastTarget?.closest({json.dumps(selector)})'):
|
||||
raise RuntimeError('Native click missed parent control ' + selector)
|
||||
|
||||
def hold_parent(selector, seconds):
|
||||
x, y = parent_point(selector)
|
||||
count = parent.eval('window.__vrParentClickCount')
|
||||
vr.post_mouse(300, x, y)
|
||||
try:
|
||||
vr.post_mouse(301, x, y, 1)
|
||||
time.sleep(seconds)
|
||||
offset = panel.eval('document.getElementById("vr-scroll-probe").scrollTop')
|
||||
finally:
|
||||
vr.post_mouse(302, x, y, 1)
|
||||
wait(lambda:parent.eval('window.__vrParentClickCount'), lambda n:n > count, seconds=3)
|
||||
if not parent.eval(f'!!window.__vrParentLastTarget?.closest({json.dumps(selector)})'):
|
||||
raise RuntimeError('Native hold missed parent control ' + selector)
|
||||
return offset
|
||||
|
||||
try:
|
||||
parent.eval(f'(async()=>{{await refreshDecky();openFrameyPanel({plugin_id},"Framey VR Check");return true}})()')
|
||||
wait(lambda:parent.eval('byId("frameyPanelStatus").innerText'), lambda s:s == 'Plugin panel ready')
|
||||
@@ -172,6 +193,10 @@ def exercise(profile, plugin_id, folder):
|
||||
result['buttonScrollAfterUp'] = wait(
|
||||
lambda:panel.eval('document.getElementById("vr-scroll-probe").scrollTop'), lambda n:n == 0)
|
||||
result['buttonScrollMoved'] = result['buttonScrollAfterDown'] > 0 and result['buttonScrollAfterUp'] == 0
|
||||
result['holdScrollAfterDown'] = hold_parent('#frameyScrollDown', 1.2)
|
||||
result['holdScrollAfterUp'] = hold_parent('#frameyScrollUp', 2.0)
|
||||
result['holdScrollMoved'] = (result['holdScrollAfterDown'] > result['buttonScrollAfterDown']
|
||||
and result['holdScrollAfterUp'] == 0)
|
||||
panel.eval('document.getElementById("vr-scroll-probe").remove()')
|
||||
panel.eval('document.getElementById("vr-controls").remove()')
|
||||
# Scroll saved status into the visible iframe for compositor evidence.
|
||||
@@ -179,7 +204,8 @@ def exercise(profile, plugin_id, folder):
|
||||
result['finalOverlay'] = vr.snapshot()
|
||||
result['passed'] = (result['trustedOverlayPointer'] and result['diskSetting'] == {'enabled':True}
|
||||
and result['reopenedChecked'] and result['sliderValue'] != '10'
|
||||
and result['textValue'] == 'framey-ci' and result['buttonScrollMoved'])
|
||||
and result['textValue'] == 'framey-ci' and result['buttonScrollMoved']
|
||||
and result['holdScrollMoved'])
|
||||
return result
|
||||
except Exception as exc:
|
||||
result['error'] = str(exc)
|
||||
|
||||
@@ -5,6 +5,7 @@ import vm from 'node:vm';
|
||||
const html=readFileSync(new URL('../../ui/headset.html',import.meta.url),'utf8');
|
||||
const source=html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
|
||||
const elements=new Map(),listeners=[],requests=[],replies=[];
|
||||
const timeouts=new Map(),intervals=new Map();let nextTimer=0;
|
||||
const el=id=>{if(!elements.has(id))elements.set(id,{removeAttribute(){},scrollIntoView(){}});return elements.get(id);};
|
||||
const frame={postMessage:data=>replies.push(data)};
|
||||
el('frameyFrame').contentWindow=frame;
|
||||
@@ -12,7 +13,10 @@ const context=vm.createContext({document:{getElementById:el,createElement:()=>({
|
||||
location:{hash:'',pathname:'/'},history:{replaceState(){}},
|
||||
sessionStorage:{getItem:()=>null,setItem(){}},localStorage:{getItem:()=>null,setItem(){}},
|
||||
window:{addEventListener:(type,fn)=>listeners.push({type,fn})},
|
||||
setTimeout(){},setInterval(){},console,
|
||||
setTimeout(fn){const id=++nextTimer;timeouts.set(id,fn);return id;},
|
||||
clearTimeout(id){timeouts.delete(id);},
|
||||
setInterval(fn){const id=++nextTimer;intervals.set(id,fn);return id;},
|
||||
clearInterval(id){intervals.delete(id);},console,
|
||||
fetch:(path,options)=>new Promise(resolve=>requests.push({path,body:JSON.parse(options.body),resolve}))});
|
||||
vm.runInContext(source,context);
|
||||
vm.runInContext('deckyState={running:true,plugins:[{storeId:96,sha256:"'+'a'.repeat(64)+'",version:"1"}]};openFrameyPanel(96,"Timer")',context);
|
||||
@@ -101,13 +105,58 @@ el('frameyScrollDown').onclick();
|
||||
assert.equal(replies.at(-1).type,'framey:scroll');
|
||||
assert.equal(replies.at(-1).session,scrollSession);
|
||||
assert.equal(replies.at(-1).direction,'down');
|
||||
const down=el('frameyScrollDown');
|
||||
const beforeHold=replies.length;
|
||||
down.onpointerdown({button:0,isPrimary:true,pointerId:7});
|
||||
assert.equal(replies.length,beforeHold+1,'pointer press scrolls once');
|
||||
down.onclick({detail:1});
|
||||
assert.equal(replies.length,beforeHold+1,'pointer click must not scroll twice');
|
||||
timeouts.get(nextTimer)();
|
||||
assert.equal(intervals.size,1);
|
||||
intervals.get(nextTimer)();
|
||||
assert.equal(replies.length,beforeHold+2,'held pointer scrolls again');
|
||||
down.onpointerup({pointerId:7});
|
||||
assert.equal(intervals.size,0,'release stops scrolling');
|
||||
down.onclick({detail:0});
|
||||
assert.equal(replies.length,beforeHold+3,'keyboard click scrolls once');
|
||||
await send({type:'framey:scroll:result',session:'old',direction:'down',moved:true});
|
||||
assert.equal(el('frameyPanelStatus').textContent,'Loading plugin panel…');
|
||||
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:true});
|
||||
assert.equal(el('frameyPanelStatus').textContent,'Scrolled plugin down');
|
||||
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:false});
|
||||
assert.equal(el('frameyPanelStatus').textContent,'No more plugin content in that direction');
|
||||
down.onpointerdown({button:0,isPrimary:true,pointerId:8});
|
||||
timeouts.get(nextTimer)();
|
||||
assert.equal(intervals.size,1);
|
||||
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:false});
|
||||
assert.equal(intervals.size,0,'end of content stops held scrolling');
|
||||
down.onpointerdown({button:0,isPrimary:true,pointerId:9});
|
||||
const lastHoldTimer=nextTimer;
|
||||
assert.equal(timeouts.has(lastHoldTimer),true);
|
||||
listeners.find(({type})=>type==='blur').fn();
|
||||
assert.equal(timeouts.has(lastHoldTimer),false,'loss of panel focus cancels a held scroll');
|
||||
// WindowProxy also survives panel navigation for lifecycle and input messages.
|
||||
// An old iframe must not mark a replacement ready, hide its error, or record
|
||||
// controller evidence for the new panel.
|
||||
const beforeStatusRequests=requests.length;
|
||||
for(const type of ['framey:ready','framey:input','framey:error']) {
|
||||
await send({type,session:'old',message:'old panel failed'});
|
||||
await send({type,message:'missing session'});
|
||||
}
|
||||
assert.equal(requests.length,beforeStatusRequests);
|
||||
assert.equal(el('frameyPanelStatus').textContent,'No more plugin content in that direction');
|
||||
await send({type:'framey:ready',session:scrollSession});
|
||||
assert.equal(el('frameyPanelStatus').textContent,'Plugin panel ready');
|
||||
assert.equal(requests.at(-1).path,'/api/framey/check');
|
||||
assert.equal(requests.at(-1).body.kind,'panel');
|
||||
await send({type:'framey:input',session:scrollSession});
|
||||
assert.equal(requests.at(-1).body.kind,'input');
|
||||
await send({type:'framey:error',session:scrollSession,message:'current panel failed'});
|
||||
assert.equal(el('frameyPanelStatus').textContent,'Plugin panel unavailable: current panel failed');
|
||||
down.onpointerdown({button:0,isPrimary:true,pointerId:10});
|
||||
const closeTimer=nextTimer;
|
||||
el('frameyClose').onclick();
|
||||
assert.equal(timeouts.has(closeTimer),false,'closing the panel cancels a held scroll');
|
||||
const replyCount=replies.length;
|
||||
el('frameyScrollUp').onclick();
|
||||
assert.equal(replies.length,replyCount);
|
||||
@@ -20,13 +20,15 @@ const elements=new Map(), roots=new Map();
|
||||
const document={getElementById:id=>{
|
||||
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);
|
||||
},createElement:()=>new Element()};
|
||||
const window={React,ReactDOM:{createRoot:node=>{
|
||||
const window={React,location:{hash:'#sample-session'},ReactDOM:{createRoot:node=>{
|
||||
const root={render(value){this.value=value;},unmount(){this.unmounted=true;}};
|
||||
roots.set(node,root);return root;
|
||||
}},addEventListener(){}};
|
||||
const listeners=[];
|
||||
const calls=[];
|
||||
const messages=[];
|
||||
const parent={postMessage(message){
|
||||
messages.push(message);
|
||||
if(message.type!=='framey:call')return;
|
||||
calls.push(message);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,
|
||||
@@ -36,6 +38,12 @@ const context=vm.createContext({window,document,
|
||||
navigator:{language:'en'},parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
|
||||
setTimeout:()=>1,clearTimeout(){}});
|
||||
vm.runInContext(host,context);
|
||||
const pointerup=listeners.find(listener=>listener.type==='pointerup').handler;
|
||||
pointerup({isTrusted:false});
|
||||
assert.equal(messages.some(message=>message.type==='framey:input'),false);
|
||||
pointerup({isTrusted:true});
|
||||
assert.equal(messages.at(-1).type,'framey:input');
|
||||
assert.equal(messages.at(-1).session,'sample-session');
|
||||
Object.assign(context,{SP_REACT:window.SP_REACT,SP_JSX:window.SP_JSX,DFL:window.DFL});
|
||||
vm.runInContext(bundle.replace(/export \{ index as default \};/, 'window.sample = index;'),context);
|
||||
const plugin=window.sample();
|
||||
|
||||
+4
-3
@@ -190,7 +190,7 @@
|
||||
parent.postMessage({type:'framey:events:close', session:cacheSession}, '*');
|
||||
});
|
||||
addEventListener('pointerup', event => {
|
||||
if (event.isTrusted) parent.postMessage({type:'framey:input'}, '*');
|
||||
if (event.isTrusted) parent.postMessage({type:'framey:input', session:cacheSession}, '*');
|
||||
}, {passive:true});
|
||||
addEventListener('pointermove', event => {
|
||||
if (event.isTrusted) lastScrollTarget = scrollTarget(event.target);
|
||||
@@ -829,10 +829,11 @@
|
||||
if (!plugin?.content && !plugin?.settings) throw Error('The Decky plugin has no panel to display');
|
||||
window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null,
|
||||
R.createElement(PluginSurface, {plugin})));
|
||||
parent.postMessage({type:'framey:ready'}, '*');
|
||||
parent.postMessage({type:'framey:ready', session:cacheSession}, '*');
|
||||
} catch (failure) {
|
||||
error.textContent = `This Decky panel is not compatible with Framey: ${failure.message}`;
|
||||
parent.postMessage({type:'framey:error', message:failure.message}, '*');
|
||||
parent.postMessage({type:'framey:error', session:cacheSession,
|
||||
message:failure.message}, '*');
|
||||
}
|
||||
}
|
||||
start();
|
||||
|
||||
+50
-8
@@ -55,6 +55,7 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
|
||||
<div class="row" style="margin-top:14px"><button id="deckyStorePrevious" class="secondary">Previous</button><span id="deckyStorePage"></span><button id="deckyStoreNext" class="secondary">Next</button></div></div>
|
||||
<div id="frameyPanel" hidden><div class="row" style="margin-top:16px"><strong id="frameyTitle"></strong><button id="frameyClose" class="secondary">Close plugin panel</button>
|
||||
<button id="frameyScrollUp" class="secondary">Scroll plugin up</button><button id="frameyScrollDown" class="secondary">Scroll plugin down</button></div>
|
||||
<p><small>Hold a scroll button to keep moving through plugin content.</small></p>
|
||||
<p id="frameyCompatibility"></p>
|
||||
<p id="frameyPanelStatus" role="status">Loading plugin panel…</p>
|
||||
<iframe id="frameyFrame" title="Framey Decky plugin panel" sandbox="allow-scripts allow-popups" referrerpolicy="no-referrer"></iframe></div>
|
||||
@@ -228,6 +229,7 @@ byId('deckyJobCheck').onclick=checkDeckyJob;
|
||||
function openFrameyPanel(id,name){
|
||||
if(deckyBusy||deckyJobPending()||!deckyState?.running)return;
|
||||
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===id);if(!installed)return;
|
||||
stopFrameyScrollHold();
|
||||
byId('frameyCompatibility').textContent=compatibilityText(installed);
|
||||
closeFrameyEvents();
|
||||
activeFrameyId=id;activeFrameyReceipt={sha256:installed.sha256,version:installed.version};
|
||||
@@ -249,15 +251,52 @@ function closeFrameyEvents(){
|
||||
session:frameySession,events:[],cursor:0}).catch(()=>{});
|
||||
frameyEventsActive=false;
|
||||
}
|
||||
function closeFrameyPanel(){closeFrameyEvents();activeFrameyId=null;activeFrameyReceipt=null;frameySession=null;
|
||||
function closeFrameyPanel(){stopFrameyScrollHold();closeFrameyEvents();activeFrameyId=null;activeFrameyReceipt=null;frameySession=null;
|
||||
byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;}
|
||||
byId('frameyClose').onclick=closeFrameyPanel;
|
||||
function scrollFramey(direction){
|
||||
if(activeFrameyId===null||!frameySession||!['up','down'].includes(direction))return;
|
||||
byId('frameyFrame').contentWindow?.postMessage({type:'framey:scroll',session:frameySession,direction},'*');
|
||||
if(activeFrameyId===null||!frameySession||!['up','down'].includes(direction))return false;
|
||||
const target=byId('frameyFrame').contentWindow;if(!target)return false;
|
||||
target.postMessage({type:'framey:scroll',session:frameySession,direction},'*');
|
||||
return true;
|
||||
}
|
||||
byId('frameyScrollUp').onclick=()=>scrollFramey('up');
|
||||
byId('frameyScrollDown').onclick=()=>scrollFramey('down');
|
||||
let frameyScrollHold=null;
|
||||
function stopFrameyScrollHold(){
|
||||
if(!frameyScrollHold)return;
|
||||
clearTimeout(frameyScrollHold.delay);
|
||||
clearInterval(frameyScrollHold.repeat);
|
||||
frameyScrollHold=null;
|
||||
}
|
||||
function bindFrameyScroll(id,direction){
|
||||
const button=byId(id);
|
||||
button.onpointerdown=event=>{
|
||||
if(event.button!==0||event.isPrimary===false)return;
|
||||
stopFrameyScrollHold();
|
||||
if(!scrollFramey(direction))return;
|
||||
const hold={button,direction,pointerId:event.pointerId,delay:null,repeat:null};
|
||||
frameyScrollHold=hold;
|
||||
hold.delay=setTimeout(()=>{
|
||||
if(frameyScrollHold!==hold)return;
|
||||
hold.repeat=setInterval(()=>{
|
||||
if(frameyScrollHold!==hold)return;
|
||||
if(!scrollFramey(direction))stopFrameyScrollHold();
|
||||
},180);
|
||||
},400);
|
||||
};
|
||||
const stop=event=>{
|
||||
if(frameyScrollHold?.button===button&&frameyScrollHold.pointerId===event.pointerId)
|
||||
stopFrameyScrollHold();
|
||||
};
|
||||
button.onpointerup=stop;
|
||||
button.onpointercancel=stop;
|
||||
button.onpointerleave=stop;
|
||||
// Pointerdown already sent one step. Keyboard activation emits a click with
|
||||
// detail 0; direct onclick calls from older tests also mean one step.
|
||||
button.onclick=event=>{if(!event||event.detail===0)scrollFramey(direction);};
|
||||
}
|
||||
bindFrameyScroll('frameyScrollUp','up');
|
||||
bindFrameyScroll('frameyScrollDown','down');
|
||||
window.addEventListener('blur',stopFrameyScrollHold);
|
||||
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
|
||||
function frameyCacheRead(id){
|
||||
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
|
||||
@@ -282,6 +321,7 @@ window.addEventListener('message',async event=>{
|
||||
if(['up','down'].includes(req.direction)&&typeof req.moved==='boolean')
|
||||
byId('frameyPanelStatus').textContent=req.moved?'Scrolled plugin '+req.direction:
|
||||
'No more plugin content in that direction';
|
||||
if(req.moved===false&&frameyScrollHold?.direction===req.direction)stopFrameyScrollHold();
|
||||
return;
|
||||
}
|
||||
if(req.type==='framey:cache:init'&&req.session===frameySession){
|
||||
@@ -292,10 +332,12 @@ window.addEventListener('message',async event=>{
|
||||
if(req.session===frameySession)frameyCacheSave(activeFrameyId,req.entries);
|
||||
return;
|
||||
}
|
||||
if(req.type==='framey:ready'){byId('frameyPanelStatus').textContent='Plugin panel ready';
|
||||
if(req.type==='framey:ready'&&req.session===frameySession){byId('frameyPanelStatus').textContent='Plugin panel ready';
|
||||
api('/api/framey/check',{kind:'panel',pluginId:activeFrameyId}).catch(()=>{});return;}
|
||||
if(req.type==='framey:input'){api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
|
||||
if(req.type==='framey:error'){byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
|
||||
if(req.type==='framey:input'&&req.session===frameySession){
|
||||
api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
|
||||
if(req.type==='framey:error'&&req.session===frameySession){
|
||||
byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
|
||||
if(req.type==='framey:running:close'){
|
||||
if(req.session===frameySession&&frameyRunningActive){
|
||||
frameyRunningActive=false;
|
||||
|
||||
Reference in new issue
Block a user