Separate official widget authorization and document account-only RPC capabilities
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;}
|
||||||
|
|||||||
@@ -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)});
|
||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user