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 {}
|
||||
current_id = cover.get("id", selected.get("coverId"))
|
||||
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(
|
||||
source="live",
|
||||
configured=True,
|
||||
catalog=catalog,
|
||||
current=current,
|
||||
current_verified=current is not None,
|
||||
status="OK" if current else "CURRENT_UNVERIFIED",
|
||||
current_verified=current is not None or no_widget,
|
||||
status="OK"
|
||||
if current
|
||||
else "NO_WIDGET"
|
||||
if no_widget
|
||||
else "CURRENT_UNVERIFIED",
|
||||
)
|
||||
except FlowError:
|
||||
raise
|
||||
|
||||
@@ -81,7 +81,8 @@ async function officialContentInfo(){
|
||||
try {
|
||||
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);
|
||||
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('');};
|
||||
info.querySelector('input').oninput=render;render();
|
||||
}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
|
||||
client.headers.clear()
|
||||
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