mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 09:00:35 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f720883a |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
__pycache__/
|
__pycache__/
|
||||||
apk-catalog/data/cache/
|
apk-catalog/data/cache/
|
||||||
apk-catalog/data/index-v2*.json*
|
apk-catalog/data/index-v2.json*
|
||||||
compat-db/.env.lakebed.server
|
compat-db/.env.lakebed.server
|
||||||
compat-db/.lakebed/
|
compat-db/.lakebed/
|
||||||
tests/smoke/results/
|
tests/smoke/results/
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Announcing changes
|
||||||
|
|
||||||
|
How we tell people about Frame Control features and fixes as they merge. The
|
||||||
|
same few sentences feed the X post, the release notes and the website, so they
|
||||||
|
are written once, in the pull request, while the change is fresh.
|
||||||
|
|
||||||
|
## What gets announced
|
||||||
|
|
||||||
|
| Kind | Announce? | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| **New** — something you can now do | Yes, its own post | Stream Mac windows into the Frame as panels |
|
||||||
|
| **Better** — something existing got noticeably easier, faster or wider | Yes, its own post or a roundup | APKs install without the Android SDK |
|
||||||
|
| **Fixed** — something broken that users hit | Yes if people reported it or it blocked a flow; otherwise the next roundup | Mac mirror showed a zoomed-in corner |
|
||||||
|
| **Release** — a tagged build | Always, one post linking the release | Frame Control 0.3.1 |
|
||||||
|
| Tests, refactors, CI, docs-only, website polish | No | Fake Frame tests, screenshot crop |
|
||||||
|
|
||||||
|
If a change isn't worth a sentence to someone who owns a Frame, it isn't
|
||||||
|
announced.
|
||||||
|
|
||||||
|
## The voice
|
||||||
|
|
||||||
|
Write it the way the README and release notes already read.
|
||||||
|
|
||||||
|
- **Lead with what the person can now do**, in their words: "Install older
|
||||||
|
versions of an app when the newest won't run on the Frame", not "Add APK
|
||||||
|
version fallback resolver".
|
||||||
|
- **Plain and specific.** Name the thing, give the number: "about 30 fps",
|
||||||
|
"4,500 apps", "up to 8 older versions". No "blazing", "game-changing",
|
||||||
|
"excited to announce", "huge", or exclamation marks.
|
||||||
|
- **Say where it works.** Platforms and what it was tested on, briefly:
|
||||||
|
"Tested on a real Frame from macOS 27." Don't claim what wasn't tested.
|
||||||
|
- **Say the catch.** If it needs a setup step, an unsigned build, or only works
|
||||||
|
on one OS, say so in the same post.
|
||||||
|
- **Sentence case**, full sentences, British spelling to match the docs.
|
||||||
|
Contractions are fine.
|
||||||
|
- **No emoji in the text.** One image, GIF or short clip carries the tone
|
||||||
|
instead. The only symbol is the kind label below.
|
||||||
|
- **Unofficial, always.** Never imply Valve made or endorses it. Say "Steam
|
||||||
|
Frame" for the headset and "Frame Control" for the app.
|
||||||
|
- **Credit people.** If a user reported the bug or suggested the feature and is
|
||||||
|
happy to be named, thank them by handle.
|
||||||
|
|
||||||
|
## The formats
|
||||||
|
|
||||||
|
Every announceable PR ends with an `## Announcement` section holding these.
|
||||||
|
The reviewer checks it like code.
|
||||||
|
|
||||||
|
### 1. The post (X, and any other social account)
|
||||||
|
|
||||||
|
```
|
||||||
|
<Kind>: <what you can do now, one sentence>
|
||||||
|
|
||||||
|
<one or two sentences: how it works, the catch, or what it was tested on>
|
||||||
|
|
||||||
|
<link>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<Kind>` is `New`, `Better` or `Fixed`.
|
||||||
|
- 280 characters maximum including the link (X counts any link as 23).
|
||||||
|
- One link: the release if it has shipped, otherwise the PR.
|
||||||
|
- One visual when the change is visible: a screenshot from the app, a GIF, or a
|
||||||
|
short clip from the headset. Alt text describes what it shows.
|
||||||
|
- No hashtags, except `#SteamFrame` on releases and on posts about something
|
||||||
|
new, because people search for it.
|
||||||
|
|
||||||
|
### 2. The release-note line
|
||||||
|
|
||||||
|
One bullet under **New in x.y.z**, same as the current release notes: the
|
||||||
|
first half of the post's first sentence, no kind label, no link.
|
||||||
|
|
||||||
|
### 3. Release post
|
||||||
|
|
||||||
|
```
|
||||||
|
Frame Control <version>: <the headline change>
|
||||||
|
|
||||||
|
<one sentence on the headline change>. Also: <two or three short items>.
|
||||||
|
|
||||||
|
Windows, macOS and Linux: <release link>
|
||||||
|
#SteamFrame
|
||||||
|
```
|
||||||
|
|
||||||
|
The release title on GitHub uses the same `Frame Control <version>: <headline>`
|
||||||
|
line, as 0.3.0 and 0.3.1 already do.
|
||||||
|
|
||||||
|
### Roundups
|
||||||
|
|
||||||
|
Small fixes that don't earn their own post wait for a roundup, posted with the
|
||||||
|
next release or when three or more have piled up:
|
||||||
|
|
||||||
|
```
|
||||||
|
Fixed in Frame Control this week:
|
||||||
|
- <fix>
|
||||||
|
- <fix>
|
||||||
|
- <fix>
|
||||||
|
|
||||||
|
<link>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples from what has already merged
|
||||||
|
|
||||||
|
**#11, older APK versions**
|
||||||
|
|
||||||
|
```
|
||||||
|
New: when an Android app is too new for the Frame, Frame Control now offers
|
||||||
|
older versions that will install.
|
||||||
|
|
||||||
|
It checks F-Droid, its archive and IzzyOnDroid, and verifies each download
|
||||||
|
before it goes on the headset.
|
||||||
|
|
||||||
|
https://github.com/saphid/steam-frame/pull/11
|
||||||
|
```
|
||||||
|
|
||||||
|
**#8, Mac mirror fixes**
|
||||||
|
|
||||||
|
```
|
||||||
|
Fixed: mirroring your Mac into the Steam Frame now fits the whole desktop in
|
||||||
|
the panel, asks for the right password, and shows the cursor.
|
||||||
|
|
||||||
|
Tested end to end on a real Frame from macOS 27.
|
||||||
|
|
||||||
|
https://github.com/saphid/steam-frame/pull/8
|
||||||
|
```
|
||||||
|
|
||||||
|
**v0.3.1**
|
||||||
|
|
||||||
|
```
|
||||||
|
Frame Control 0.3.1: install APKs without the Android SDK
|
||||||
|
|
||||||
|
Frame Control now reads APK files itself, so there's nothing extra to install.
|
||||||
|
Also: Linux and Windows game sideloading, and one-click install links.
|
||||||
|
|
||||||
|
Windows, macOS and Linux: https://github.com/saphid/steam-frame/releases/tag/v0.3.1
|
||||||
|
#SteamFrame
|
||||||
|
```
|
||||||
|
|
||||||
|
## Posting
|
||||||
|
|
||||||
|
Nothing is posted without a person approving it. The flow is:
|
||||||
|
|
||||||
|
1. The PR carries its `## Announcement` section.
|
||||||
|
2. On merge, the post is drafted from that section (manually for now).
|
||||||
|
3. Alex approves or edits it, then it's posted from the project account.
|
||||||
|
4. Replies and questions that turn out to be bugs become GitHub issues labelled
|
||||||
|
`feedback`, same as the website form.
|
||||||
@@ -46,33 +46,6 @@ Lepton Development must be installed once. Over SSH,
|
|||||||
`ssh frame 'steam steam://install/3056000'` queues it, but the install still
|
`ssh frame 'steam steam://install/3056000'` queues it, but the install still
|
||||||
needs to be confirmed or started in the headset.
|
needs to be confirmed or started in the headset.
|
||||||
|
|
||||||
## When an app needs a newer Android
|
|
||||||
|
|
||||||
Lepton is Android 11 (API 30), with arm64-v8a only. If Frame Control refuses
|
|
||||||
an APK, it shows compatible versions from F-Droid's main and archive repos and
|
|
||||||
IzzyOnDroid. It shows at most eight version names, newest first, preferring an
|
|
||||||
arm64-only build, and says how many compatible builds it found in total.
|
|
||||||
Each index is reduced to its compatible builds once a day and cached (about
|
|
||||||
16 MB). The first lookup takes about 30 s and 100 MB of memory; later ones are
|
|
||||||
instant.
|
|
||||||
Choose **Install** to download a listed version, verify its SHA-256 against
|
|
||||||
the index, and install it as its own app.
|
|
||||||
|
|
||||||
You can also inspect a file or look up a package from the command line:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 ui/frame_android.py info some-app.apk
|
|
||||||
python3 ui/frame_android.py versions some-app.apk
|
|
||||||
python3 ui/frame_android.py versions org.example.app
|
|
||||||
```
|
|
||||||
|
|
||||||
The search links open APKMirror, APKPure, Uptodown, F-Droid and GitHub. Pick a
|
|
||||||
version whose minimum is Android 11 or lower and that has an arm64-v8a build
|
|
||||||
(or no native code). Frame Control does not fetch APKs from those search sites.
|
|
||||||
Older versions may lack fixes, and being installable does not guarantee an
|
|
||||||
app will run: see the missing services below. Android may refuse a downgrade
|
|
||||||
or an update signed by a different publisher; removing the app deletes its data.
|
|
||||||
|
|
||||||
## Installed apps disappear when Lepton Development closes (verified 2026-09-25)
|
## Installed apps disappear when Lepton Development closes (verified 2026-09-25)
|
||||||
|
|
||||||
Lepton Development runs in a throwaway "dev" context. When it exits for any
|
Lepton Development runs in a throwaway "dev" context. When it exits for any
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign
|
|||||||
foreign_label adds a non-android `label` attribute after android:label.
|
foreign_label adds a non-android `label` attribute after android:label.
|
||||||
"""
|
"""
|
||||||
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
|
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
|
||||||
'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
|
'application', package, 'junk', 'label'] # the second 'label' has no android id
|
||||||
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
|
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
|
||||||
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
|
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
|
||||||
|
|
||||||
@@ -48,8 +48,7 @@ def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign
|
|||||||
none = 0xffffffff
|
none = 0xffffffff
|
||||||
chunks = (pool(strings) + resmap
|
chunks = (pool(strings) + resmap
|
||||||
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
|
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
|
||||||
(2, none, frame_apk.T_REF, version_ref),
|
(2, none, frame_apk.T_REF, version_ref)])
|
||||||
(11, none, frame_apk.T_INT_DEC, 210)])
|
|
||||||
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
|
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
|
||||||
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
|
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
|
||||||
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
|
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
|
||||||
@@ -109,7 +108,6 @@ class ApkInfo(unittest.TestCase):
|
|||||||
self.assertEqual(info['package'], 'com.example.demo')
|
self.assertEqual(info['package'], 'com.example.demo')
|
||||||
self.assertEqual(info['label'], 'App label') # the default, not French
|
self.assertEqual(info['label'], 'App label') # the default, not French
|
||||||
self.assertEqual(info['version'], '2.1')
|
self.assertEqual(info['version'], '2.1')
|
||||||
self.assertEqual(info['version_code'], 210)
|
|
||||||
self.assertEqual(info['min_sdk'], 26)
|
self.assertEqual(info['min_sdk'], 26)
|
||||||
self.assertEqual(info['abis'], ['arm64-v8a', 'x86_64'])
|
self.assertEqual(info['abis'], ['arm64-v8a', 'x86_64'])
|
||||||
self.assertEqual(info['icon_png'], b'hi') # largest-density PNG, skipping the XML icon
|
self.assertEqual(info['icon_png'], b'hi') # largest-density PNG, skipping the XML icon
|
||||||
|
|||||||
@@ -1,260 +0,0 @@
|
|||||||
"""Offline version lookup with small index-v2 fixtures."""
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
|
|
||||||
import frame_apk_versions as versions
|
|
||||||
import frame_catalog
|
|
||||||
import frame_android
|
|
||||||
|
|
||||||
|
|
||||||
def build(code, sdk=30, abis=None):
|
|
||||||
return {'manifest': {'versionName': str(code), 'versionCode': code,
|
|
||||||
'usesSdk': {'minSdkVersion': sdk}, 'nativecode': abis or []},
|
|
||||||
'file': {'name': f'/example_{code}.apk', 'sha256': str(code).zfill(64)}}
|
|
||||||
|
|
||||||
|
|
||||||
class VersionsTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.addCleanup(self.tmp.cleanup)
|
|
||||||
data = os.path.join(self.tmp.name, 'data')
|
|
||||||
os.mkdir(data)
|
|
||||||
for name, builds in [('index-v2.json', [build(5, 33), build(4, abis=['x86_64']),
|
|
||||||
build(3, abis=['arm64-v8a', 'x86_64']), build(2)]),
|
|
||||||
('index-v2.archive.json', [build(1, 21), build(2)]),
|
|
||||||
('index-v2.izzy.json', [])]:
|
|
||||||
with open(os.path.join(data, name), 'w') as f:
|
|
||||||
json.dump({'packages': {'org.example.app': {'versions': {str(i): b for i, b in enumerate(builds)}}}}, f)
|
|
||||||
self.enter_patch(patch.object(frame_catalog, 'CATALOG', self.tmp.name))
|
|
||||||
self.enter_patch(patch.dict(os.environ, {'FRAME_CONTROL_APP': ''}))
|
|
||||||
self.network = self.enter_patch(patch.object(frame_catalog.urllib.request, 'urlopen', side_effect=AssertionError('network used')))
|
|
||||||
|
|
||||||
def enter_patch(self, p):
|
|
||||||
result = p.start()
|
|
||||||
self.addCleanup(p.stop)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def test_filter_order_archive_and_dedup(self):
|
|
||||||
result = versions.alternatives('org.example.app')
|
|
||||||
self.assertEqual([v['version_code'] for v in result['versions']], [3, 2, 1])
|
|
||||||
self.assertEqual(result['versions'][-1]['source'], 'F-Droid archive')
|
|
||||||
self.assertEqual(result['versions'][-1]['url'], 'https://f-droid.org/archive/example_1.apk')
|
|
||||||
self.assertEqual(result['errors'], [])
|
|
||||||
self.network.assert_not_called()
|
|
||||||
|
|
||||||
def test_current_version(self):
|
|
||||||
result = versions.alternatives('org.example.app', 3)
|
|
||||||
self.assertEqual([v['version_code'] for v in result['versions']], [2, 1])
|
|
||||||
|
|
||||||
def test_fallback(self):
|
|
||||||
result = versions.alternatives('com.missing.app')
|
|
||||||
self.assertEqual(result['versions'], [])
|
|
||||||
self.assertEqual([v['source'] for v in result['links']], ['APKMirror', 'APKPure', 'Uptodown', 'F-Droid', 'GitHub'])
|
|
||||||
self.assertTrue(all('com.missing.app' in v['url'] for v in result['links']))
|
|
||||||
self.assertIn('Android 11', result['note'])
|
|
||||||
self.assertIn('arm64-v8a', result['note'])
|
|
||||||
|
|
||||||
def test_failed_indexes_keep_search_links(self):
|
|
||||||
with patch.object(frame_catalog, 'load_index', side_effect=OSError('offline')):
|
|
||||||
result = versions.alternatives('org.example.app')
|
|
||||||
self.assertEqual(len(result['errors']), 3)
|
|
||||||
self.assertEqual(len(result['links']), 5)
|
|
||||||
|
|
||||||
def test_no_compatible_versions(self):
|
|
||||||
with patch.object(frame_catalog, 'load_index', return_value={}):
|
|
||||||
result = versions.alternatives('org.example.app')
|
|
||||||
self.assertEqual(result['versions'], [])
|
|
||||||
self.assertEqual(len(result['links']), 5)
|
|
||||||
|
|
||||||
def test_reduction_memory_cache_and_refresh(self):
|
|
||||||
repo = versions.REPOS[0][1]
|
|
||||||
index = frame_catalog.load_index(repo)
|
|
||||||
self.assertEqual([v['version_code'] for v in index['org.example.app']], [3, 2])
|
|
||||||
self.assertEqual(set(index['org.example.app'][0]),
|
|
||||||
{'version', 'version_code', 'min_sdk', 'abis', 'name', 'sha256'})
|
|
||||||
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json')
|
|
||||||
self.assertTrue(os.path.exists(raw)) # the catalogue build reads it
|
|
||||||
os.utime(raw, ns=(1, 1))
|
|
||||||
with patch.object(frame_catalog.json, 'load', side_effect=AssertionError('reparsed')):
|
|
||||||
self.assertIs(frame_catalog.load_index(repo), index)
|
|
||||||
path = raw + '.installable-v1'
|
|
||||||
with open(path, 'w') as f:
|
|
||||||
json.dump({}, f)
|
|
||||||
os.utime(path, ns=(1, 1))
|
|
||||||
self.assertEqual(frame_catalog.load_index(repo, cached_only=True), {})
|
|
||||||
payload = json.dumps({'packages': {'org.example.app': {'versions': {'x': build(9)}}}}).encode()
|
|
||||||
with patch.object(frame_catalog.urllib.request, 'urlopen', return_value=io.BytesIO(payload)) as fetch:
|
|
||||||
self.assertEqual(frame_catalog.load_index(repo)['org.example.app'][0]['version_code'], 9)
|
|
||||||
fetch.assert_called_once()
|
|
||||||
self.assertTrue(os.path.exists(raw))
|
|
||||||
|
|
||||||
def test_malformed_entries_are_skipped(self):
|
|
||||||
raw = os.path.join(self.tmp.name, 'odd.json')
|
|
||||||
with open(raw, 'w') as f:
|
|
||||||
json.dump({'packages': {'a.b': {'versions': {'x': {'manifest': {}}, 'y': None, 'z': build(4)}},
|
|
||||||
'c.d': {'versions': None}, 'e.f': []}}, f)
|
|
||||||
self.assertEqual([v['version_code'] for v in frame_catalog._reduce_index(raw)['a.b']], [4])
|
|
||||||
|
|
||||||
def test_one_failing_repo_keeps_the_others(self):
|
|
||||||
real = frame_catalog.load_index
|
|
||||||
def load(repo, cached_only=False):
|
|
||||||
if 'izzy' in repo:
|
|
||||||
raise KeyError('file')
|
|
||||||
return real(repo, cached_only=cached_only)
|
|
||||||
with patch.object(frame_catalog, 'load_index', side_effect=load):
|
|
||||||
result = versions.alternatives('org.example.app')
|
|
||||||
self.assertEqual([v['version_code'] for v in result['versions']], [3, 2, 1])
|
|
||||||
self.assertEqual(len(result['errors']), 1)
|
|
||||||
|
|
||||||
def test_newer_raw_index_outdates_reduced_copy(self):
|
|
||||||
repo = versions.REPOS[0][1]
|
|
||||||
frame_catalog.load_index(repo)
|
|
||||||
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json')
|
|
||||||
with open(raw, 'w') as f:
|
|
||||||
json.dump({'packages': {'org.example.app': {'versions': {'x': build(8)}}}}, f)
|
|
||||||
os.utime(raw, ns=(time.time_ns() + 10**9,) * 2)
|
|
||||||
self.assertEqual(frame_catalog.load_index(repo)['org.example.app'][0]['version_code'], 8)
|
|
||||||
|
|
||||||
def test_concurrent_requests_share_download(self):
|
|
||||||
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json')
|
|
||||||
os.remove(raw)
|
|
||||||
payload = json.dumps({'packages': {'org.example.app': {'versions': {'x': build(7)}}}}).encode()
|
|
||||||
with patch.object(frame_catalog.urllib.request, 'urlopen', side_effect=lambda *a, **k: io.BytesIO(payload)) as fetch:
|
|
||||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
||||||
indexes = list(pool.map(frame_catalog.load_index, [versions.REPOS[0][1]] * 4))
|
|
||||||
fetch.assert_called_once()
|
|
||||||
self.assertTrue(all(index is indexes[0] for index in indexes))
|
|
||||||
|
|
||||||
def test_failed_refresh_preserves_cache(self):
|
|
||||||
repo = versions.REPOS[0][1]
|
|
||||||
index = frame_catalog.load_index(repo)
|
|
||||||
path = os.path.join(self.tmp.name, 'data', 'index-v2.json.installable-v1')
|
|
||||||
os.utime(path, ns=(1, 1))
|
|
||||||
os.utime(os.path.join(self.tmp.name, 'data', 'index-v2.json'), ns=(1, 1))
|
|
||||||
with patch.object(frame_catalog.urllib.request, 'urlopen', return_value=io.BytesIO(b'{')):
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
frame_catalog.load_index(repo)
|
|
||||||
self.assertEqual(frame_catalog.load_index(repo, cached_only=True), index)
|
|
||||||
self.assertFalse(any(name.endswith('.part') for name in os.listdir(os.path.dirname(path))))
|
|
||||||
|
|
||||||
def test_stream_boundaries_and_invalid_index(self):
|
|
||||||
raw = os.path.join(self.tmp.name, 'stream.json')
|
|
||||||
with open(raw, 'w') as f:
|
|
||||||
json.dump({'repo': {'description': 'é' * 70000}, 'packages': {
|
|
||||||
'org.example.app': {'metadata': {'text': 'escaped " packages { }' * 6000},
|
|
||||||
'versions': {'x': build(7)}}}, 'tail': {}}, f)
|
|
||||||
self.assertEqual(frame_catalog._reduce_index(raw)['org.example.app'][0]['version_code'], 7)
|
|
||||||
for invalid in ('{}', '{"packages": []}', '{"packages": {', '{"packages": {}} trailing'):
|
|
||||||
with open(raw, 'w') as f:
|
|
||||||
f.write(invalid)
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
frame_catalog._reduce_index(raw)
|
|
||||||
|
|
||||||
def test_cap_and_preferred_build(self):
|
|
||||||
records = [dict(version=str(i), version_code=i, min_sdk=21, abis=[],
|
|
||||||
name='/app_%s.apk' % i, sha256=str(i)) for i in range(20)]
|
|
||||||
records += [dict(records[-1], version_code=21, abis=['arm64-v8a'], name='/arm.apk'),
|
|
||||||
dict(records[-1], version_code=22, abis=['arm64-v8a', 'x86_64'], name='/all.apk')]
|
|
||||||
with patch.object(frame_catalog, 'load_index', return_value={'org.example.app': records}):
|
|
||||||
result = versions.alternatives('org.example.app')
|
|
||||||
self.assertEqual(result['total'], 22)
|
|
||||||
self.assertEqual(len(result['versions']), 8)
|
|
||||||
self.assertEqual(len({v['version'] for v in result['versions']}), 8)
|
|
||||||
self.assertEqual([v['version_code'] for v in result['versions']], [21, 18, 17, 16, 15, 14, 13, 12])
|
|
||||||
|
|
||||||
def test_android_names_and_verdict(self):
|
|
||||||
for sdk, name in [(23, 'Android 6.0'), (30, 'Android 11'), (32, 'Android 12L'), (33, 'Android 13'), (99, 'Android API 99')]:
|
|
||||||
self.assertEqual(versions.android_name(sdk), name)
|
|
||||||
info = {'package': 'org.example.app', 'label': 'Example', 'version': '5.0',
|
|
||||||
'version_code': 50, 'min_sdk': 33, 'abis': ['arm64-v8a']}
|
|
||||||
description = versions.describe(info)
|
|
||||||
for text in ['org.example.app', '5.0', 'code 50', 'Android 13', 'arm64-v8a', 'cannot install']:
|
|
||||||
self.assertIn(text, description)
|
|
||||||
info.update(min_sdk=30, abis=[])
|
|
||||||
self.assertIn('can install', versions.describe(info))
|
|
||||||
info['abis'] = ['armeabi-v7a']
|
|
||||||
self.assertIn('no arm64-v8a build', versions.describe(info))
|
|
||||||
|
|
||||||
def test_install_resolves_index_hash(self):
|
|
||||||
versions.alternatives('org.example.app')
|
|
||||||
with patch.object(versions, 'alternatives', side_effect=AssertionError('recomputed')), \
|
|
||||||
patch.object(frame_catalog, 'fetch_apk', return_value='/tmp/example.apk') as fetch, \
|
|
||||||
patch.object(frame_android, 'apk_info', return_value={'package': 'org.example.app', 'version_code': 1}), \
|
|
||||||
patch.object(frame_android, 'install', return_value={'label': 'Example'}) as install:
|
|
||||||
versions.install('org.example.app', 'https://f-droid.org/archive/example_1.apk')
|
|
||||||
self.assertEqual(fetch.call_args[0][0]['h'], str(1).zfill(64))
|
|
||||||
install.assert_called_once_with('/tmp/example.apk', source='F-Droid archive')
|
|
||||||
with self.assertRaises(frame_android.FrameError):
|
|
||||||
versions.install('org.example.app', 'https://evil.example/app.apk')
|
|
||||||
|
|
||||||
def test_install_checks_identity_without_network_refresh(self):
|
|
||||||
versions.alternatives('org.example.app')
|
|
||||||
for name in ('index-v2.json', 'index-v2.archive.json'):
|
|
||||||
os.utime(os.path.join(self.tmp.name, 'data', name + '.installable-v1'), ns=(1, 1))
|
|
||||||
for info in ({'package': 'wrong.package', 'version_code': 1},
|
|
||||||
{'package': 'org.example.app', 'version_code': 99}):
|
|
||||||
with patch.object(frame_catalog, 'fetch_apk', return_value='/tmp/example.apk'), \
|
|
||||||
patch.object(frame_android, 'apk_info', return_value=info), \
|
|
||||||
patch.object(frame_android, 'install') as install:
|
|
||||||
with self.assertRaises(frame_android.FrameError):
|
|
||||||
versions.install('org.example.app', 'https://f-droid.org/archive/example_1.apk')
|
|
||||||
install.assert_not_called()
|
|
||||||
self.network.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class UploadVersionsTest(unittest.TestCase):
|
|
||||||
def test_endpoint_validation(self):
|
|
||||||
import server
|
|
||||||
for query in ('', 'package=', 'package=foo', 'package=a..b', 'package=a.1b',
|
|
||||||
'package=a.b/path', 'package=a.b&package=c.d', 'package=a.b&code=-1',
|
|
||||||
'package=a.b&code=x', 'package=a.b&code=', 'package=a.b&code=1&code=2'):
|
|
||||||
handler = object.__new__(server.Handler)
|
|
||||||
handler.path = '/api/apk-versions?' + query
|
|
||||||
with patch.object(handler, 'local_request', return_value=True), \
|
|
||||||
patch.object(handler, 'send_json') as reply, \
|
|
||||||
patch.object(versions, 'alternatives') as lookup:
|
|
||||||
handler.do_GET()
|
|
||||||
self.assertEqual(reply.call_args[0][1], 400, query)
|
|
||||||
lookup.assert_not_called()
|
|
||||||
handler.path = '/api/apk-versions?package=org.example_app.demo&code=123'
|
|
||||||
with patch.object(handler, 'local_request', return_value=True), \
|
|
||||||
patch.object(handler, 'send_json') as reply, \
|
|
||||||
patch.object(versions, 'alternatives', return_value={'total': 0}) as lookup:
|
|
||||||
handler.do_GET()
|
|
||||||
lookup.assert_called_once_with('org.example_app.demo', 123)
|
|
||||||
reply.assert_called_once_with({'total': 0})
|
|
||||||
|
|
||||||
def test_blocked_uploads_do_not_lookup_before_reply(self):
|
|
||||||
import server
|
|
||||||
info = {'package': 'org.example.app', 'label': 'Example', 'version': '5',
|
|
||||||
'version_code': 5, 'min_sdk': 33, 'abis': [], 'icon_png': None}
|
|
||||||
for mode in ('apkinfo', 'apk'):
|
|
||||||
handler = object.__new__(server.Handler)
|
|
||||||
handler.headers = {'X-Filename': 'app.apk', 'X-Mode': mode, 'Content-Length': '1'}
|
|
||||||
handler.rfile = io.BytesIO(b'x')
|
|
||||||
with patch.object(frame_android, 'apk_info', return_value=dict(info)), \
|
|
||||||
patch.object(versions, 'alternatives', side_effect=AssertionError('lookup during upload')) as lookup, \
|
|
||||||
patch.object(server, 'ensure_master') as ssh:
|
|
||||||
if mode == 'apkinfo':
|
|
||||||
reply = handler.upload()
|
|
||||||
self.assertNotIn('alternatives', reply['apk'])
|
|
||||||
self.assertIn('API 33', reply['apk']['blocker'])
|
|
||||||
else:
|
|
||||||
with self.assertRaises(server.Failure) as error:
|
|
||||||
handler.upload()
|
|
||||||
self.assertEqual(error.exception.status, 400)
|
|
||||||
self.assertEqual(error.exception.apk['package'], info['package'])
|
|
||||||
lookup.assert_not_called()
|
|
||||||
ssh.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -212,23 +212,6 @@ class ServerGuards(unittest.TestCase):
|
|||||||
self.assertNotEqual(status, 200, body)
|
self.assertNotEqual(status, 200, body)
|
||||||
self.assertNotIn("job", body)
|
self.assertNotIn("job", body)
|
||||||
|
|
||||||
def test_android_install_of_another_version_runs_as_a_job(self):
|
|
||||||
pkg = "org.example.frame_control.not_in_any_repo"
|
|
||||||
sys.path.insert(0, str(ROOT / "ui"))
|
|
||||||
import frame_apk_versions
|
|
||||||
# The job must fail on the cached lookup, before any download: nothing is cached for this package.
|
|
||||||
self.assertEqual(frame_apk_versions._versions(pkg, cached_only=True)[0], [])
|
|
||||||
status, started = self.post("/api/android", {"action": "install", "package": pkg,
|
|
||||||
"url": f"https://f-droid.org/repo/{pkg}_1.apk"})
|
|
||||||
self.assertEqual(status, 200, started)
|
|
||||||
for _ in range(200):
|
|
||||||
job = json.loads(self.request("GET", f"/api/job?id={started['job']}", headers={"X-Frame-UI": "1"})[2])
|
|
||||||
if job["done"]:
|
|
||||||
break
|
|
||||||
time.sleep(0.05)
|
|
||||||
self.assertTrue(job["done"])
|
|
||||||
self.assertIn("no longer available", job["error"])
|
|
||||||
|
|
||||||
def test_unknown_routes(self):
|
def test_unknown_routes(self):
|
||||||
self.assertEqual(self.request("GET", "/nope")[0], 404)
|
self.assertEqual(self.request("GET", "/nope")[0], 404)
|
||||||
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
||||||
|
|||||||
+2
-10
@@ -6,7 +6,7 @@ apps, the lepton-show-flatscreen marker; plus a non-Steam shortcut, so it shows
|
|||||||
in the Steam library and gets its own SteamVR panel. Nothing goes through
|
in the Steam library and gets its own SteamVR panel. Nothing goes through
|
||||||
Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
||||||
|
|
||||||
Python stdlib only. CLI: python3 ui/frame_android.py {info APK|versions APK-or-PKG|install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
|
Python stdlib only. CLI: python3 ui/frame_android.py {install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
|
||||||
"""
|
"""
|
||||||
import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib
|
import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib
|
||||||
|
|
||||||
@@ -276,15 +276,7 @@ def probe(pkg, wait=20):
|
|||||||
def main():
|
def main():
|
||||||
cmd, *args = sys.argv[1:] or ['help']
|
cmd, *args = sys.argv[1:] or ['help']
|
||||||
try:
|
try:
|
||||||
if cmd in ('info', 'versions'):
|
if cmd == 'install':
|
||||||
import frame_apk_versions
|
|
||||||
if cmd == 'info':
|
|
||||||
print(frame_apk_versions.describe(apk_info(args[0])))
|
|
||||||
return
|
|
||||||
info = apk_info(args[0]) if os.path.isfile(args[0]) or args[0].lower().endswith('.apk') else None
|
|
||||||
r = frame_apk_versions.alternatives(
|
|
||||||
info['package'] if info else args[0], info.get('version_code') if info else None)
|
|
||||||
elif cmd == 'install':
|
|
||||||
r = install(args[0], flatscreen='--vr' not in args)
|
r = install(args[0], flatscreen='--vr' not in args)
|
||||||
elif cmd == 'list':
|
elif cmd == 'list':
|
||||||
r = list_apps()
|
r = list_apps()
|
||||||
|
|||||||
@@ -229,7 +229,6 @@ def apk_info(path):
|
|||||||
min_sdk = sdk.get('minSdkVersion')
|
min_sdk = sdk.get('minSdkVersion')
|
||||||
info = {
|
info = {
|
||||||
'package': package,
|
'package': package,
|
||||||
'version_code': manifest.get('versionCode', (None, None))[1],
|
|
||||||
'version': _text(manifest.get('versionName'), res) or '',
|
'version': _text(manifest.get('versionName'), res) or '',
|
||||||
'label': _text(app.get('label'), res) or package,
|
'label': _text(app.get('label'), res) or package,
|
||||||
'abis': sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2}),
|
'abis': sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2}),
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
"""Explain APK requirements and find installable versions in F-Droid's indexes."""
|
|
||||||
from urllib.parse import quote, urlencode
|
|
||||||
|
|
||||||
import frame_android
|
|
||||||
import frame_catalog
|
|
||||||
|
|
||||||
ANDROID = dict(enumerate([
|
|
||||||
'1.0', '1.1', '1.5', '1.6', '2.0', '2.0.1', '2.1', '2.2', '2.3', '2.3.3',
|
|
||||||
'3.0', '3.1', '3.2', '4.0', '4.0.3', '4.1', '4.2', '4.3', '4.4', '4.4W',
|
|
||||||
'5.0', '5.1', '6.0', '7.0', '7.1', '8.0', '8.1', '9', '10', '11', '12',
|
|
||||||
'12L', '13', '14', '15', '16',
|
|
||||||
], 1))
|
|
||||||
REPOS = (('F-Droid', 'https://f-droid.org/repo/'),
|
|
||||||
('F-Droid archive', 'https://f-droid.org/archive/'),
|
|
||||||
('IzzyOnDroid', 'https://apt.izzysoft.de/fdroid/repo/'))
|
|
||||||
NOTE = ('Pick a version whose minimum is Android 11 or lower and that has an '
|
|
||||||
'arm64-v8a build (or no native code). Installable does not mean every feature works.')
|
|
||||||
|
|
||||||
|
|
||||||
def android_name(sdk):
|
|
||||||
return 'Android ' + ANDROID[sdk] if sdk in ANDROID else f'Android API {sdk}'
|
|
||||||
|
|
||||||
|
|
||||||
def describe(info):
|
|
||||||
sdk = info.get('min_sdk')
|
|
||||||
minimum = f'{android_name(sdk)} (API {sdk})' if sdk else 'not specified'
|
|
||||||
try:
|
|
||||||
frame_android.check_installable(info)
|
|
||||||
verdict = 'Lepton can install this APK. Features may still need services Lepton lacks.'
|
|
||||||
except frame_android.FrameError as e:
|
|
||||||
verdict = f'Lepton cannot install this APK: {e}'
|
|
||||||
return (f"{info['package']} · {info.get('version') or '?'} "
|
|
||||||
f"(code {info.get('version_code') if info.get('version_code') is not None else '?'})\n"
|
|
||||||
f"Minimum: {minimum}\nABIs: {', '.join(info['abis']) or 'no native code'}\n{verdict}")
|
|
||||||
|
|
||||||
|
|
||||||
def search_links(package):
|
|
||||||
q = quote(package, safe='')
|
|
||||||
return [{'source': name, 'url': url} for name, url in (
|
|
||||||
('APKMirror', 'https://www.apkmirror.com/?' + urlencode({'post_type': 'app_release', 's': package})),
|
|
||||||
('APKPure', 'https://apkpure.com/search?q=' + q),
|
|
||||||
('Uptodown', 'https://en.uptodown.com/android/search/' + q),
|
|
||||||
('F-Droid', 'https://search.f-droid.org/?q=' + q),
|
|
||||||
('GitHub', 'https://github.com/search?type=repositories&q=' + q),
|
|
||||||
)]
|
|
||||||
|
|
||||||
|
|
||||||
def _versions(package, cached_only=False):
|
|
||||||
versions, errors, seen = [], [], set()
|
|
||||||
for source, repo in REPOS:
|
|
||||||
try:
|
|
||||||
index = frame_catalog.load_index(repo, cached_only=cached_only)
|
|
||||||
except Exception as e: # one bad repo (dropped download, odd index) mustn't hide the others
|
|
||||||
errors.append(f'Could not check {source}: {e}')
|
|
||||||
continue
|
|
||||||
for v in index.get(package, []):
|
|
||||||
url = repo + v['name'].lstrip('/')
|
|
||||||
key = (v['version_code'], v.get('sha256') or url)
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
versions.append(dict(v, url=url, source=source))
|
|
||||||
return versions, errors
|
|
||||||
|
|
||||||
|
|
||||||
def alternatives(package, current_version_code=None):
|
|
||||||
"""At most eight releases, preferring arm64-only builds over universal builds."""
|
|
||||||
versions, errors = _versions(package)
|
|
||||||
versions = [v for v in versions if v['version_code'] != current_version_code]
|
|
||||||
total = len(versions)
|
|
||||||
versions.sort(key=lambda v: (v['abis'] == ['arm64-v8a'], v['version_code']), reverse=True)
|
|
||||||
releases = {}
|
|
||||||
for v in versions:
|
|
||||||
releases.setdefault(v['version'], v)
|
|
||||||
versions = sorted(releases.values(), key=lambda v: v['version_code'], reverse=True)[:8]
|
|
||||||
return {'package': package, 'versions': versions, 'total': total,
|
|
||||||
'links': search_links(package), 'note': NOTE, 'errors': errors}
|
|
||||||
|
|
||||||
|
|
||||||
def install(package, url):
|
|
||||||
# Resolve the selection again: the client cannot supply a trusted hash or arbitrary URL.
|
|
||||||
records, _ = _versions(package, cached_only=True)
|
|
||||||
version = next((v for v in records if v['url'] == url), None)
|
|
||||||
if not version:
|
|
||||||
raise frame_android.FrameError('That version is no longer available; check the APK again')
|
|
||||||
apk = frame_catalog.fetch_apk({'a': version['url'], 'h': version['sha256'], 'n': package})
|
|
||||||
info = frame_android.apk_info(apk)
|
|
||||||
if info['package'] != package or info.get('version_code') != version['version_code']:
|
|
||||||
raise frame_android.FrameError('The downloaded APK does not match the selected version')
|
|
||||||
return frame_android.install(apk, source=version['source'])
|
|
||||||
+2
-139
@@ -2,7 +2,7 @@
|
|||||||
list, verified downloads, installs into per-app Lepton instances, and
|
list, verified downloads, installs into per-app Lepton instances, and
|
||||||
compatibility reports. Python stdlib only.
|
compatibility reports. Python stdlib only.
|
||||||
"""
|
"""
|
||||||
import hashlib, json, os, shutil, sys, tempfile, threading, time, urllib.error, urllib.request
|
import hashlib, os, shutil, sys, tempfile, threading, time, urllib.error, urllib.request
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
CATALOG = os.path.join(ROOT, 'apk-catalog')
|
CATALOG = os.path.join(ROOT, 'apk-catalog')
|
||||||
@@ -17,149 +17,12 @@ import frame_compat_db as compat_db # noqa: E402
|
|||||||
# the per-user cache (FRAME_CONTROL_APP is set by app/main.js).
|
# the per-user cache (FRAME_CONTROL_APP is set by app/main.js).
|
||||||
CACHE = (str(frame_host.cache_dir('apk')) if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG
|
CACHE = (str(frame_host.cache_dir('apk')) if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG
|
||||||
else os.path.join(CATALOG, 'data', 'cache'))
|
else os.path.join(CATALOG, 'data', 'cache'))
|
||||||
# Repo base URL -> local name of its index; every APK download must come from one of these.
|
APK_HOSTS = ('https://f-droid.org/repo/', 'https://f-droid.org/archive/')
|
||||||
INDEX_FILES = {'https://f-droid.org/repo/': 'index-v2.json',
|
|
||||||
'https://f-droid.org/archive/': 'index-v2.archive.json',
|
|
||||||
'https://apt.izzysoft.de/fdroid/repo/': 'index-v2.izzy.json'}
|
|
||||||
APK_HOSTS = tuple(INDEX_FILES)
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
_cache = {'mtime': None, 'sig': None, 'apps': None, 'by_pkg': None}
|
_cache = {'mtime': None, 'sig': None, 'apps': None, 'by_pkg': None}
|
||||||
_env = {}
|
_env = {}
|
||||||
|
|
||||||
|
|
||||||
_index_lock = threading.Lock()
|
|
||||||
_indexes = {}
|
|
||||||
|
|
||||||
|
|
||||||
class _IndexReader:
|
|
||||||
"""Decode one object member at a time; never retain the whole raw index."""
|
|
||||||
def __init__(self, stream):
|
|
||||||
self.stream, self.buffer = stream, ''
|
|
||||||
self.decoder = json.JSONDecoder()
|
|
||||||
|
|
||||||
def fill(self):
|
|
||||||
chunk = self.stream.read(1 << 16)
|
|
||||||
if not chunk:
|
|
||||||
raise ValueError('incomplete F-Droid index')
|
|
||||||
self.buffer += chunk
|
|
||||||
|
|
||||||
def peek(self):
|
|
||||||
self.buffer = self.buffer.lstrip()
|
|
||||||
while not self.buffer:
|
|
||||||
self.fill()
|
|
||||||
self.buffer = self.buffer.lstrip()
|
|
||||||
return self.buffer[0]
|
|
||||||
|
|
||||||
def expect(self, char):
|
|
||||||
if self.peek() != char:
|
|
||||||
raise ValueError('invalid F-Droid index')
|
|
||||||
self.buffer = self.buffer[1:]
|
|
||||||
|
|
||||||
def value(self):
|
|
||||||
self.peek()
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
value, end = self.decoder.raw_decode(self.buffer)
|
|
||||||
self.buffer = self.buffer[end:]
|
|
||||||
return value
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self.fill()
|
|
||||||
|
|
||||||
def members(self):
|
|
||||||
self.expect('{')
|
|
||||||
if self.peek() != '}':
|
|
||||||
while True:
|
|
||||||
key = self.value()
|
|
||||||
if not isinstance(key, str):
|
|
||||||
raise ValueError('invalid F-Droid index key')
|
|
||||||
self.expect(':')
|
|
||||||
yield key
|
|
||||||
if self.peek() == '}':
|
|
||||||
break
|
|
||||||
self.expect(',')
|
|
||||||
self.expect('}')
|
|
||||||
|
|
||||||
|
|
||||||
def _reduce_index(path):
|
|
||||||
from pick import installable
|
|
||||||
packages = {}
|
|
||||||
found = False
|
|
||||||
with open(path, encoding='utf-8') as f:
|
|
||||||
reader = _IndexReader(f)
|
|
||||||
for key in reader.members():
|
|
||||||
if key != 'packages':
|
|
||||||
reader.value()
|
|
||||||
continue
|
|
||||||
found = True
|
|
||||||
for package in reader.members():
|
|
||||||
records, entry = [], reader.value()
|
|
||||||
versions = entry.get('versions') if isinstance(entry, dict) else None
|
|
||||||
for v in (versions.values() if isinstance(versions, dict) else ()):
|
|
||||||
# Skip malformed entries rather than losing the whole repo.
|
|
||||||
if not (isinstance(v, dict) and isinstance(v.get('manifest'), dict)
|
|
||||||
and isinstance(v.get('file'), dict) and v['file'].get('name')):
|
|
||||||
continue
|
|
||||||
if not installable(v):
|
|
||||||
continue
|
|
||||||
m, file = v['manifest'], v['file']
|
|
||||||
records.append({'version': m.get('versionName', ''),
|
|
||||||
'version_code': m.get('versionCode', 0),
|
|
||||||
'min_sdk': m.get('usesSdk', {}).get('minSdkVersion', 1),
|
|
||||||
'abis': m.get('nativecode') or [],
|
|
||||||
'name': file['name'], 'sha256': file.get('sha256')})
|
|
||||||
if records:
|
|
||||||
packages[package] = records
|
|
||||||
if reader.buffer.strip() or f.read().strip():
|
|
||||||
raise ValueError('trailing data in F-Droid index')
|
|
||||||
if not found:
|
|
||||||
raise ValueError('invalid F-Droid index')
|
|
||||||
return packages
|
|
||||||
|
|
||||||
|
|
||||||
def load_index(repo, cached_only=False):
|
|
||||||
"""Compact installable records by package, cached on disk and by mtime in memory."""
|
|
||||||
if repo not in APK_HOSTS:
|
|
||||||
raise ValueError('unexpected index URL')
|
|
||||||
directory = CACHE if os.environ.get('FRAME_CONTROL_APP') or '.app/Contents/Resources' in CATALOG else os.path.join(CATALOG, 'data')
|
|
||||||
filename = INDEX_FILES[repo]
|
|
||||||
raw = os.path.join(directory, filename)
|
|
||||||
path = raw + '.installable-v1'
|
|
||||||
with _index_lock:
|
|
||||||
mtime = os.stat(path).st_mtime_ns if os.path.exists(path) else None
|
|
||||||
# A newer raw index (the catalogue script refreshed it) outdates the reduced copy.
|
|
||||||
newer_raw = mtime is not None and os.path.exists(raw) and os.stat(raw).st_mtime_ns > mtime
|
|
||||||
if mtime is not None and (cached_only or (time.time() - mtime / 1e9 < 86400 and not newer_raw)):
|
|
||||||
cached = _indexes.get(path)
|
|
||||||
if cached is None or cached[0] != mtime:
|
|
||||||
with open(path) as f:
|
|
||||||
cached = (mtime, json.load(f))
|
|
||||||
_indexes[path] = cached
|
|
||||||
return cached[1]
|
|
||||||
if cached_only:
|
|
||||||
return {}
|
|
||||||
os.makedirs(directory, exist_ok=True)
|
|
||||||
fd, tmp = tempfile.mkstemp(prefix=filename, suffix='.part', dir=directory)
|
|
||||||
os.close(fd)
|
|
||||||
try:
|
|
||||||
if os.path.exists(raw) and time.time() - os.path.getmtime(raw) < 86400:
|
|
||||||
index = _reduce_index(raw)
|
|
||||||
refreshed = os.stat(raw).st_mtime_ns
|
|
||||||
else:
|
|
||||||
with open(tmp, 'wb') as f, urllib.request.urlopen(repo + 'index-v2.json', timeout=30) as r:
|
|
||||||
shutil.copyfileobj(r, f, 1 << 20)
|
|
||||||
index = _reduce_index(tmp)
|
|
||||||
refreshed = time.time_ns()
|
|
||||||
with open(tmp, 'w') as f:
|
|
||||||
json.dump(index, f, separators=(',', ':'))
|
|
||||||
os.utime(tmp, ns=(refreshed, refreshed))
|
|
||||||
os.replace(tmp, path)
|
|
||||||
_indexes[path] = (os.stat(path).st_mtime_ns, index)
|
|
||||||
return index
|
|
||||||
finally:
|
|
||||||
if os.path.exists(tmp):
|
|
||||||
os.remove(tmp)
|
|
||||||
|
|
||||||
|
|
||||||
def catalog():
|
def catalog():
|
||||||
"""Rated apps with the database's reports applied."""
|
"""Rated apps with the database's reports applied."""
|
||||||
path = os.path.join(CATALOG, 'site', 'apps.js')
|
path = os.path.join(CATALOG, 'site', 'apps.js')
|
||||||
|
|||||||
+3
-58
@@ -254,10 +254,10 @@
|
|||||||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||||||
.and-col { display: grid; gap: 22px; align-content: start; }
|
.and-col { display: grid; gap: 22px; align-content: start; }
|
||||||
.rep-item .s { white-space: normal; }
|
.rep-item .s { white-space: normal; }
|
||||||
#repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
#repDlg, #titleDlg, #wiDlg, #pwDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||||
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop { background: rgba(0,0,0,.55); }
|
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||||
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||||
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||||
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
||||||
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||||
@@ -656,16 +656,6 @@
|
|||||||
<span class="last" id="lastLog">Ready</span>
|
<span class="last" id="lastLog">Ready</span>
|
||||||
<span class="sub" id="drawerHint">Show ▴</span>
|
<span class="sub" id="drawerHint">Show ▴</span>
|
||||||
</div>
|
</div>
|
||||||
<dialog id="apkAltDlg" aria-labelledby="apkAltTitle">
|
|
||||||
<h2 id="apkAltTitle">Try another APK version</h2>
|
|
||||||
<p id="apkAltReason"></p>
|
|
||||||
<div class="list" id="apkAltVersions"></div>
|
|
||||||
<p class="sub" id="apkAltNote"></p>
|
|
||||||
<div id="apkAltLinks"></div>
|
|
||||||
<p class="sub" id="apkAltErrors"></p>
|
|
||||||
<div class="actions"><button id="apkAltClose">Close</button></div>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog id="repDlg" aria-labelledby="repTitle">
|
<dialog id="repDlg" aria-labelledby="repTitle">
|
||||||
<form method="dialog" id="repForm">
|
<form method="dialog" id="repForm">
|
||||||
<h2 id="repTitle">Report an APK</h2>
|
<h2 id="repTitle">Report an APK</h2>
|
||||||
@@ -1420,56 +1410,12 @@ function upload(file, mode) {
|
|||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
$("prog").style.display = "none";
|
$("prog").style.display = "none";
|
||||||
let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; }
|
let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; }
|
||||||
if (data.apk?.blocker && xhr.status >= 300) checkApkAlternatives(data.apk);
|
|
||||||
xhr.status < 300 ? resolve(data) : reject(new Error(data.error));
|
xhr.status < 300 ? resolve(data) : reject(new Error(data.error));
|
||||||
};
|
};
|
||||||
xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); };
|
xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); };
|
||||||
xhr.send(file);
|
xhr.send(file);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let apkLookup = 0;
|
|
||||||
async function checkApkAlternatives(apk) {
|
|
||||||
const lookup = ++apkLookup;
|
|
||||||
$("apkAltReason").textContent = apk.blocker;
|
|
||||||
$("apkAltVersions").textContent = "Checking F-Droid for older versions…";
|
|
||||||
$("apkAltVersions").onclick = null;
|
|
||||||
for (const id of ["apkAltNote", "apkAltErrors", "apkAltLinks"]) $(id).textContent = "";
|
|
||||||
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
|
|
||||||
const query = new URLSearchParams({package: apk.package});
|
|
||||||
if (apk.version_code != null) query.set("code", apk.version_code);
|
|
||||||
try {
|
|
||||||
const result = await api(`/api/apk-versions?${query}`);
|
|
||||||
if (lookup === apkLookup && $("apkAltDlg").open) showApkAlternatives(apk.blocker, result);
|
|
||||||
} catch (e) {
|
|
||||||
if (lookup === apkLookup) $("apkAltVersions").textContent = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function showApkAlternatives(reason, result) {
|
|
||||||
$("apkAltReason").textContent = reason;
|
|
||||||
$("apkAltNote").textContent = `${result.versions.length} of ${result.total} compatible versions. ${result.note}`;
|
|
||||||
$("apkAltErrors").textContent = result.errors.join(" · ");
|
|
||||||
$("apkAltLinks").innerHTML = result.links.map(l => `<a href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.source)}</a>`).join(" · ");
|
|
||||||
$("apkAltVersions").innerHTML = result.versions.length ? result.versions.map((v, i) =>
|
|
||||||
`<div class="item"><div class="grow"><div class="t">${esc(v.version || "?")} <span class="sub">code ${esc(v.version_code)}</span></div>
|
|
||||||
<div class="s">${esc(v.source)} · minimum API ${esc(v.min_sdk)} · ${esc(v.abis.join(", ") || "no native code")}</div></div>
|
|
||||||
<button class="small" data-version="${i}" ${v.sha256 ? "" : "disabled"}>Install</button></div>`).join("") :
|
|
||||||
`<p>No compatible version found in F-Droid. Try the searches below.</p>`;
|
|
||||||
$("apkAltVersions").onclick = async e => {
|
|
||||||
const b = e.target.closest("[data-version]"); if (!b) return;
|
|
||||||
const v = result.versions[+b.dataset.version];
|
|
||||||
if (installing.has(result.package)) return;
|
|
||||||
b.disabled = true; b.textContent = "Installing…";
|
|
||||||
const res = await runJob(`Install ${result.package} ${v.version}`, result.package, () => api("/api/android", {
|
|
||||||
action: "install", package: result.package, url: v.url
|
|
||||||
}));
|
|
||||||
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
|
|
||||||
await loadAndroid();
|
|
||||||
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
|
|
||||||
};
|
|
||||||
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
|
|
||||||
}
|
|
||||||
$("apkAltClose").onclick = () => $("apkAltDlg").close();
|
|
||||||
|
|
||||||
const TITLE_EXT = /\.(zip|exe)$/i;
|
const TITLE_EXT = /\.(zip|exe)$/i;
|
||||||
async function sendFiles(files, dirs = new Set()) {
|
async function sendFiles(files, dirs = new Set()) {
|
||||||
for (const [i, f] of files.entries()) {
|
for (const [i, f] of files.entries()) {
|
||||||
@@ -2050,7 +1996,6 @@ $("repFile").onchange = async () => {
|
|||||||
const { apk } = await upload(file, "apkinfo");
|
const { apk } = await upload(file, "apkinfo");
|
||||||
$("repPkg").value = apk.package; $("repVer").value = apk.version; $("repLabel").value = apk.label;
|
$("repPkg").value = apk.package; $("repVer").value = apk.version; $("repLabel").value = apk.label;
|
||||||
if (!$("repSrc").value) $("repSrc").value = file.name;
|
if (!$("repSrc").value) $("repSrc").value = file.name;
|
||||||
if (apk.blocker) checkApkAlternatives(apk);
|
|
||||||
$("repFileNote").textContent = apk.blocker ? `Note: ${apk.blocker}` : `${apk.package} ${apk.version}`;
|
$("repFileNote").textContent = apk.blocker ? `Note: ${apk.blocker}` : `${apk.package} ${apk.version}`;
|
||||||
} catch (e) { $("repFileNote").textContent = e.message; }
|
} catch (e) { $("repFileNote").textContent = e.message; }
|
||||||
$("repFile").value = "";
|
$("repFile").value = "";
|
||||||
|
|||||||
+6
-32
@@ -37,7 +37,6 @@ from urllib.parse import parse_qs, unquote, urlparse
|
|||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
import frame_android # noqa: E402
|
import frame_android # noqa: E402
|
||||||
import frame_apk_versions # noqa: E402
|
|
||||||
import frame_catalog # noqa: E402
|
import frame_catalog # noqa: E402
|
||||||
import frame_host # noqa: E402
|
import frame_host # noqa: E402
|
||||||
import frame_store # noqa: E402
|
import frame_store # noqa: E402
|
||||||
@@ -92,10 +91,9 @@ exit 1
|
|||||||
|
|
||||||
|
|
||||||
class Failure(Exception):
|
class Failure(Exception):
|
||||||
def __init__(self, message, status=502, apk=None):
|
def __init__(self, message, status=502):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.status = status
|
self.status = status
|
||||||
self.apk = apk
|
|
||||||
|
|
||||||
|
|
||||||
# What ssh prints when it never reached the Frame, and what to tell the user
|
# What ssh prints when it never reached the Frame, and what to tell the user
|
||||||
@@ -563,28 +561,16 @@ def open_thing(body):
|
|||||||
raise Failure("unknown target", 400)
|
raise Failure("unknown target", 400)
|
||||||
|
|
||||||
|
|
||||||
def apk_versions(query):
|
|
||||||
args = parse_qs(query, keep_blank_values=True)
|
|
||||||
packages, codes = args.get('package', []), args.get('code', [])
|
|
||||||
if len(packages) != 1 or not frame_android.PKG_RE.match(packages[0]):
|
|
||||||
raise Failure('invalid Android package id', 400)
|
|
||||||
if codes and (len(codes) != 1 or not re.fullmatch(r'[0-9]{1,19}', codes[0])):
|
|
||||||
raise Failure('invalid version code', 400)
|
|
||||||
return frame_apk_versions.alternatives(packages[0], int(codes[0]) if codes else None)
|
|
||||||
|
|
||||||
|
|
||||||
def android(body):
|
def android(body):
|
||||||
"""Android apps, each in its own persistent Lepton instance (frame_android.py)."""
|
"""Android apps, each in its own persistent Lepton instance (frame_android.py)."""
|
||||||
action, pkg = body.get("action"), str(body.get("package", ""))
|
action, pkg = body.get("action"), str(body.get("package", ""))
|
||||||
ensure_master()
|
ensure_master()
|
||||||
try:
|
try:
|
||||||
if action == "install":
|
if action == "install":
|
||||||
url = body.get("url")
|
frame_catalog.app(pkg) # an unknown package fails now, not in the background
|
||||||
if not url:
|
|
||||||
frame_catalog.app(pkg) # an unknown package fails now, not in the background
|
|
||||||
|
|
||||||
def work():
|
def work():
|
||||||
m = frame_apk_versions.install(pkg, url) if url else frame_catalog.install(pkg)
|
m = frame_catalog.install(pkg)
|
||||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
|
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
|
||||||
"app": m}
|
"app": m}
|
||||||
return start_job(f"Install {pkg}", work)
|
return start_job(f"Install {pkg}", work)
|
||||||
@@ -1317,10 +1303,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def send_json(self, obj, status=200):
|
def send_json(self, obj, status=200):
|
||||||
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
||||||
|
|
||||||
def send_error_json(self, message, status, apk=None):
|
def send_error_json(self, message, status):
|
||||||
body, offline_status = error_body(message)
|
body, offline_status = error_body(message)
|
||||||
if apk is not None:
|
|
||||||
body["apk"] = apk
|
|
||||||
self.send_json(body, offline_status or status)
|
self.send_json(body, offline_status or status)
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
@@ -1335,8 +1319,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||||
"computer": "Mac" if frame_host.MAC else "PC"})
|
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||||
elif path == "/api/apk-versions":
|
|
||||||
self.send_json(apk_versions(url.query))
|
|
||||||
elif path == "/api/android":
|
elif path == "/api/android":
|
||||||
ensure_master()
|
ensure_master()
|
||||||
self.send_json({"apps": frame_android.list_apps()})
|
self.send_json({"apps": frame_android.list_apps()})
|
||||||
@@ -1376,7 +1358,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self.send_json({"error": "not found"}, 404)
|
self.send_json({"error": "not found"}, 404)
|
||||||
except Failure as e:
|
except Failure as e:
|
||||||
self.send_error_json(str(e), e.status, e.apk)
|
self.send_error_json(str(e), e.status)
|
||||||
except frame_android.FrameError as e:
|
except frame_android.FrameError as e:
|
||||||
self.send_error_json(str(e), 502)
|
self.send_error_json(str(e), 502)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1402,7 +1384,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
raise Failure("request body must be a JSON object", 400)
|
raise Failure("request body must be a JSON object", 400)
|
||||||
self.send_json(handler(body))
|
self.send_json(handler(body))
|
||||||
except Failure as e:
|
except Failure as e:
|
||||||
self.send_error_json(str(e), e.status, e.apk)
|
self.send_error_json(str(e), e.status)
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
self.send_json({"error": f"bad request: {e}"}, 400)
|
self.send_json({"error": f"bad request: {e}"}, 400)
|
||||||
except frame_android.FrameError as e:
|
except frame_android.FrameError as e:
|
||||||
@@ -1507,14 +1489,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
keep = True # stage_title owns tmp now, and removes it on failure
|
keep = True # stage_title owns tmp now, and removes it on failure
|
||||||
return stage_title(str(dest), temp_dir=str(tmp))
|
return stage_title(str(dest), temp_dir=str(tmp))
|
||||||
if mode == "apk":
|
if mode == "apk":
|
||||||
try:
|
|
||||||
info = frame_android.apk_info(str(dest))
|
|
||||||
except frame_android.FrameError as e:
|
|
||||||
raise Failure(str(e), 400)
|
|
||||||
try:
|
|
||||||
frame_android.check_installable(info)
|
|
||||||
except frame_android.FrameError as e:
|
|
||||||
raise Failure(str(e), 400, {"package": info["package"], "version_code": info.get("version_code"), "blocker": str(e)})
|
|
||||||
ensure_master()
|
ensure_master()
|
||||||
try:
|
try:
|
||||||
m = frame_android.install(str(dest), source=name)
|
m = frame_android.install(str(dest), source=name)
|
||||||
|
|||||||
Reference in new issue
Block a user