Separate official widget authorization and document account-only RPC capabilities

This commit is contained in:
Pigway
2026-10-03 07:00:07 +08:00
parent 0c18cabc92
commit 142d771ffa
5 changed files with 111 additions and 3 deletions
@@ -0,0 +1,60 @@
# KoKonna official account capabilities
Reviewed 2026-10-03. Source: official Android nightly 1.6.5 client, downloaded
from https://kokonna.art/pages/download-app. RPC method names and usage were
inspected without running or modifying the app. No vendor source is distributed.
## Two independent access paths
Frame Flow retains its existing device API Key integration. The documented
OpenAPI provides device status, upload, image listing/download/delete and display
selection by ID/name. It exposes rotation, timezone and automatic image-change
status as read fields, but does not document setters for them.
Account login accesses the official RPC service. The current device key was
explicitly tested against device discovery and widget discovery: both rejected
it with `请重新登陆`. Account login, device discovery/detail and widget discovery
have been verified successfully with HTTP 200. Other methods below are found in
client code only: their availability, permissions and side effects have not been
live-tested. Missing OpenAPI documentation is not proof that no undocumented
API Key endpoint exists.
## Additional frame-related capabilities found
| Capability | RPC methods | Evidence and boundary |
| --- | --- | --- |
| Official widgets and parameters | getFrameCovers / setFrameCover | Catalog read verified (75 items); current selection can be empty. Selection/cancellation discovered, not tested. |
| Rotation configuration | setRotate | Discovered setter; OpenAPI only reads rotation. |
| Device name and timezone | changeName / app.setTimezone | Discovered setters; OpenAPI only reads these fields. |
| Slideshow mode and period | setFrameImageChangeType / setFrameImageChangeMinute | Discovered setters; actual rotation sequence must be derived from mode/filter/order, not invented. |
| Slideshow tag filters | setFrameImageChangeTags | Discovered filter configuration. |
| Tagged, paginated image library | getImages / addTag / removeTag / setImageTags | Discovered; original client passes `page`, `paegSize` (spelling as observed) and `tags`. Requires separate validation. |
| Image renaming | changeFrameImageName | Discovered; distinct from rename-device. |
| Official gallery collections | getFrameGallerys / getFrameGalleryImages / addFrameGalleryImage | Discovered browse/add flow; may depend on entitlement. |
| Artist content | getFrameArtists / addFrameArtist | Discovered; subscriptions/selection may consume points or alter display. |
| Startup/splash image | getFrameSplashs / setSplash | Discovered browse/set flow. |
| Remote refresh | refreshImage | Discovered write; do not infer display success from RPC acceptance. |
| Image sharing/import | getShareImages / shareImage / importImagesByTransferToken | Discovered; do not expose transfer tokens or share credentials. |
| Device sharing and pairing | enablePair / enableShareCode / refreshShareUUID; app.addShareRobot / removeShare | Discovered sensitive configuration; excluded from automatic tests. |
| Firmware information/upgrade | app.getFirmwares / setUpgradeFirmware | Discovered; not enabled or tested. |
| Wake/sleep interval | setFrameImageChangeInterval | Client updates `defaultSleepTime`; do not mistake this for slideshow minutes. |
| AI/voice content | chat / recognizeVoice / text2speech | Discovered; support may vary with hardware, service and account credits. |
| Feedback and likes | reportFrameImage / likeImage | Discovered; modifies remote account data. |
RPC methods in this table use the `appFrame.` prefix unless explicitly `app.`.
Generic account methods also cover profile, password, device binding/unlinking,
credits/orders and custom assistant roles. Their presence in the shared client
is not evidence that all are supported by the 13.3-inch B101 frame. No binding,
reset, firmware, payment or subscription mutation was executed during research.
## Frame Flow implementation boundary
- API Key device/gallery/upload/display support remains unchanged.
- Official widget panel separately explains account authorization requirements.
- The existing WidgetReader uses a server-side official session secret; direct
account/password connection UI and automatic session renewal are not implemented.
- Live `NO_WIDGET` requires explicit null current-widget association and null
robot association ID; missing fields remain `CURRENT_UNVERIFIED`.
- Unconfigured access returns `AUTH_REQUIRED` and an explicitly dated snapshot.
- Widget selection and the other RPC writes above are not implemented. Account
login capability discovery is not permission to exercise those writes.
+13 -2
View File
@@ -83,13 +83,24 @@ class WidgetReader:
cover = selected.get("frameCover") or {} cover = selected.get("frameCover") or {}
current_id = cover.get("id", selected.get("coverId")) current_id = cover.get("id", selected.get("coverId"))
current = next((row for row in catalog if row.id == current_id), None) current = next((row for row in catalog if row.id == current_id), None)
robot = data.get("robot") or {}
no_widget = (
"frameCoverRobot" in data
and data["frameCoverRobot"] is None
and "frameCoverRobotId" in robot
and robot["frameCoverRobotId"] is None
)
result = WidgetState( result = WidgetState(
source="live", source="live",
configured=True, configured=True,
catalog=catalog, catalog=catalog,
current=current, current=current,
current_verified=current is not None, current_verified=current is not None or no_widget,
status="OK" if current else "CURRENT_UNVERIFIED", status="OK"
if current
else "NO_WIDGET"
if no_widget
else "CURRENT_UNVERIFIED",
) )
except FlowError: except FlowError:
raise raise
+2 -1
View File
@@ -81,7 +81,8 @@ async function officialContentInfo(){
try { try {
const data=await api('/api/v1/devices/'+state.device+'/widgets'); const data=await api('/api/v1/devices/'+state.device+'/widgets');
const label=data.source==='live'?(zh?'实时目录':'Live catalog'):data.source==='mock'?'Mock':(zh?'历史目录 · '+data.observed_at:'Catalog snapshot · '+data.observed_at); const label=data.source==='live'?(zh?'实时目录':'Live catalog'):data.source==='mock'?'Mock':(zh?'历史目录 · '+data.observed_at:'Catalog snapshot · '+data.observed_at);
info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}</summary><p>${esc(label)}</p><p class="notice">${data.current_verified?esc((zh?'当前小组件:':'Current widget: ')+data.current.name):(zh?'当前小组件尚未核实。官方微信会话与设备 API Key 是不同凭据。':'Current widget is unverified. The official session differs from the device API Key.')}</p><p>${zh?'此入口只读,不会切换或取消官方订阅。':'Read only. This does not change official subscriptions.'}</p><input type="search" aria-label="${zh?'搜索小组件':'Search widgets'}" placeholder="${zh?'搜索名称或分类':'Search name or category'}"><div class="widget-list"></div>`; const currentLabel=!data.configured?(zh?'尚未授权官方账号,当前状态不可读取。':'Official account not authorized; current state is unavailable.'):data.status==='NO_WIDGET'?(zh?'未使用官方小组件':'No official widget selected'):data.current_verified&&data.current?((zh?'当前小组件:':'Current widget: ')+data.current.name):(zh?'当前小组件尚未核实':'Current widget is unverified');
info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}${!data.configured?(zh?' · 需官方账号授权':' · Official account required'):''}</summary><p>${esc(label)}</p><p class="notice">${esc(currentLabel)}</p><p class="notice widget-auth-notice">${zh?'如需使用官方小组件,需要另外授权 KoKonna 官方账号登录信息。现有 API Key 接入继续用于设备状态、图片和图库,不受影响。':'Official widgets require separate KoKonna account authorization. Your existing API Key continues to support device status, images and gallery.'}</p><p>${zh?'此入口只读,不会切换或取消官方订阅。':'Read only. This does not change official subscriptions.'}</p><input type="search" aria-label="${zh?'搜索小组件':'Search widgets'}" placeholder="${zh?'搜索名称或分类':'Search name or category'}"><div class="widget-list"></div>`;
const render=()=>{const query=info.querySelector('input').value.toLowerCase();info.querySelector('.widget-list').innerHTML=data.catalog.filter(x=>(x.name+' '+x.tags.join(' ')).toLowerCase().includes(query)).map(x=>`<article class="card"><strong>${esc(x.name)}</strong><p>${esc(x.tags.join(' · '))}</p><small>${esc(x.arg_keys.join(' · '))}</small></article>`).join('');}; const render=()=>{const query=info.querySelector('input').value.toLowerCase();info.querySelector('.widget-list').innerHTML=data.catalog.filter(x=>(x.name+' '+x.tags.join(' ')).toLowerCase().includes(query)).map(x=>`<article class="card"><strong>${esc(x.name)}</strong><p>${esc(x.tags.join(' · '))}</p><small>${esc(x.arg_keys.join(' · '))}</small></article>`).join('');};
info.querySelector('input').oninput=render;render(); info.querySelector('input').oninput=render;render();
}catch(e){info.querySelector('p').textContent=e.message;} }catch(e){info.querySelector('p').textContent=e.message;}
+16
View File
@@ -0,0 +1,16 @@
/* Widget authorization and selection states; intercepted Mock responses only. */
const assert=require('assert'),{chromium}=require('playwright');
(async()=>{
const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));let status='AUTH_REQUIRED',checks=0;
await page.route('**/api/v1/devices/*/widgets',route=>route.fulfill({json:{source:status==='AUTH_REQUIRED'?'snapshot':'live',configured:status!=='AUTH_REQUIRED',status,observed_at:'2026-10-02',catalog:[],current:null,current_verified:status==='NO_WIDGET',writable:false}}));
await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();
for(const lang of ['zh','en'])for(const value of ['AUTH_REQUIRED','NO_WIDGET','CURRENT_UNVERIFIED']){
status=value;await page.evaluate(lang=>{state.lang=lang;return navigate('templates');},lang);
await page.locator('.widget-auth-notice').waitFor({state:'attached'});
const text=await page.locator('.official-content-info').textContent();
assert(text.includes(lang==='zh'?'现有 API Key 接入继续':'Your existing API Key continues'));
assert(text.includes(lang==='zh'?{AUTH_REQUIRED:'尚未授权官方账号',NO_WIDGET:'未使用官方小组件',CURRENT_UNVERIFIED:'当前小组件尚未核实'}[value]:{AUTH_REQUIRED:'Official account not authorized',NO_WIDGET:'No official widget selected',CURRENT_UNVERIFIED:'Current widget is unverified'}[value]));checks++;
}
assert(errors.length===0);await browser.close();console.log(`Widget UI: ${checks} states passed; 0 page errors`);
})().catch(e=>{console.error(e.message);process.exit(1)});
+20
View File
@@ -67,3 +67,23 @@ def test_widget_api_mock_and_auth(client):
assert len(response.json()["catalog"]) == 75 assert len(response.json()["catalog"]) == 75
client.headers.clear() client.headers.clear()
assert client.get("/api/v1/devices/living-room/widgets").status_code == 401 assert client.get("/api/v1/devices/living-room/widgets").status_code == 401
@pytest.mark.parametrize(
"robot,selected,status",
[
({"frameCoverRobotId": None}, None, "NO_WIDGET"),
({}, None, "CURRENT_UNVERIFIED"),
({"frameCoverRobotId": 123}, None, "CURRENT_UNVERIFIED"),
],
)
def test_no_selection_is_distinct_from_unknown(robot, selected, status):
response = Mock(status_code=200)
response.json.return_value = {
"result": {"rows": [], "robot": robot, "frameCoverRobot": selected}
}
with patch("requests.post", return_value=response):
result = WidgetReader("secret", 123).read()
assert result.status == status
assert result.current is None
assert result.current_verified == (status == "NO_WIDGET")