mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-04 19:43:49 +08:00
Update app IDs and notification handling for Galaxy app
This commit is contained in:
@@ -781,7 +781,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
token = quote(f"{record['slug']}:{record['session']}", safe='')
|
||||
if path.endswith('/qr.svg'):
|
||||
from openpilot.common.qrcode import _Qr, _capacity
|
||||
raw = ('starpilot-remote://add?key=' + quote(token, safe='')).encode()
|
||||
raw = ('starpilot-remote://add?key=' + token).encode()
|
||||
version = next(version for version in range(1, 21) if 4 + 8 + len(raw) * 8 <= _capacity(version) * 8)
|
||||
matrix = _Qr(version, raw).modules
|
||||
size = len(matrix) + 8
|
||||
|
||||
@@ -33,7 +33,7 @@ class NotificationHTTPTests(unittest.TestCase):
|
||||
return result
|
||||
try:
|
||||
cookie = request()[2]['Set-Cookie'].split(';', 1)[0]
|
||||
code, result, _ = request({'action': 'appSubscribe', 'token': 'test-token', 'appId': 'link.firestar.galaxy', 'label': 'Phone'}, cookie)
|
||||
code, result, _ = request({'action': 'appSubscribe', 'token': 'test-token', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'}, cookie)
|
||||
self.assertEqual(code, 200)
|
||||
self.assertNotIn('test-token', json.dumps(result))
|
||||
self.assertEqual(request({'action': 'test', 'target': 'app', 'id': 'missing'}, cookie)[0], 404)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { requestJson } from './startup.js'
|
||||
import { GxNotice } from './notice.js'
|
||||
import { BluetoothNotice, bluetoothConnection } from './bluetooth-notice.js'
|
||||
import { ANDROID_BETA_URL } from './install-app.js'
|
||||
|
||||
export function newerCompanionVersion(available, installed) {
|
||||
const parts = version => typeof version === 'string' && /^\d+(?:\.\d+){1,3}$/.test(version) ? version.split('.').map(Number) : []
|
||||
@@ -23,7 +24,7 @@ export const GalaxyCompanion = {
|
||||
components: { GxNotice, BluetoothNotice },
|
||||
emits: ['status'],
|
||||
props: { mode: String, unauthorized: Function },
|
||||
data: () => ({ status: null, error: '', native: !!window.StarPilotApp?.uplinkState, sharing: false, autoStart: false,
|
||||
data: () => ({ status: null, error: '', betaUrl: ANDROID_BETA_URL, native: !!window.StarPilotApp?.uplinkState, sharing: false, autoStart: false,
|
||||
bluetooth: bluetoothConnection(), nativeBytes: 0, busy: false, generation: 0, alive: false }),
|
||||
computed: { windowAutoStart() { return !!window.StarPilotApp?.setAutoStart } },
|
||||
mounted() { this.start() },
|
||||
@@ -87,7 +88,7 @@ export const GalaxyCompanion = {
|
||||
<p v-if="native"><button class="gx-btn gx-btn--tonal" @click="connectionOptions">Bluetooth, Galaxy Wi-Fi or LAN</button></p>
|
||||
<BluetoothNotice v-if="bluetooth" feature="Media transfers and phone internet">Phone internet requires the Galaxy hotspot. Settings and small requests remain available.</BluetoothNotice>
|
||||
<template v-if="!native"><p>Join the Android beta on Google Groups to get the app, then join TheGalaxy Wi-Fi.</p>
|
||||
<a class="gx-btn" href="https://groups.google.com/g/galaxy-starpilot-android-beta" target="_blank" rel="noopener">Join the Android beta</a>
|
||||
<a class="gx-btn" :href="betaUrl" target="_blank" rel="noopener">Join the Android beta</a>
|
||||
</template>
|
||||
<template v-if="status && mode === 'local'"><p>Status: <strong>{{ status.state }}</strong> {{ status.reason }} · {{ ((native ? nativeBytes : status.bytes) / 1048576).toFixed(1) }} MB today</p>
|
||||
</template><GxNotice v-if="error" tone="danger">{{ error }}</GxNotice>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GxState } from "./state.js"
|
||||
import { GxNotice } from "./notice.js"
|
||||
import { requestJson } from "./startup.js"
|
||||
import { InstallApp, isIosDevice } from "./install-app.js"
|
||||
import { ANDROID_BETA_URL, InstallApp, isIosDevice } from "./install-app.js"
|
||||
import { GalaxyCompanion, newerCompanionVersion } from "./companion.js"
|
||||
import { GalaxyHotspot } from "./hotspot.js"
|
||||
import { LocalAccess } from "./local-access.js"
|
||||
@@ -15,7 +15,7 @@ export const GalaxyPage = {
|
||||
components: { GxState, GxNotice, BluetoothNotice, InstallApp, GalaxyHotspot, GalaxyCompanion, LocalAccess },
|
||||
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
|
||||
data: () => ({ loading: true, paired: false, url: "", tunnelClientAvailable: false, legacyPassword: false,
|
||||
appKey: '', keyVisible: false, legacyPairingAvailable: false, password: "", busy: false, error: "", companionStatus: null,
|
||||
appKey: '', keyVisible: false, betaUrl: ANDROID_BETA_URL, legacyPairingAvailable: false, password: "", busy: false, error: "", companionStatus: null,
|
||||
bluetooth: bluetoothConnection(), nativeVersion: window.StarPilotApp?.version?.() || '', dismissedUpdateVersion: dismissedUpdate(),
|
||||
ios: isIosDevice() }),
|
||||
computed: {
|
||||
@@ -33,7 +33,8 @@ export const GalaxyPage = {
|
||||
mounted() { this.load() },
|
||||
methods: {
|
||||
async showAppKey() {
|
||||
try { const data = await this.request('./api/galaxy/session'); this.appKey = data.paired ? decodeURIComponent(data.sessionToken) : ''; this.keyVisible = !!this.appKey }
|
||||
const url = this.url
|
||||
try { const data = await this.request('./api/galaxy/session'); if (this.url !== url || !this.paired) return; this.appKey = data.paired ? decodeURIComponent(data.sessionToken) : ''; this.keyVisible = !!this.appKey }
|
||||
catch (error) { this.error = error.message }
|
||||
},
|
||||
async copyAppKey() {
|
||||
@@ -73,6 +74,8 @@ export const GalaxyPage = {
|
||||
const data = await this.request("./api/galaxy/pair", { password: this.password })
|
||||
this.paired = data.paired
|
||||
this.url = data.url
|
||||
this.appKey = ''
|
||||
this.keyVisible = false
|
||||
this.password = ""
|
||||
} catch (error) { this.error = error.message }
|
||||
finally { this.busy = false }
|
||||
@@ -85,6 +88,8 @@ export const GalaxyPage = {
|
||||
await this.request("./api/galaxy/unpair", {})
|
||||
this.paired = false
|
||||
this.url = ""
|
||||
this.appKey = ''
|
||||
this.keyVisible = false
|
||||
} catch (error) { this.error = error.message }
|
||||
finally { this.busy = false }
|
||||
},
|
||||
@@ -100,7 +105,7 @@ export const GalaxyPage = {
|
||||
<GxNotice v-else-if="showAppUpdate && !ios" tone="warn" title="Android app update available">
|
||||
Version {{ availableAppVersion }} is available. Installed: {{ installedAppVersion }}.
|
||||
<template #actions><button v-if="nativeInstallerAvailable" type="button" class="gx-btn" @click="installAppUpdate">Update app</button>
|
||||
<a v-else class="gx-btn" href="https://groups.google.com/g/galaxy-starpilot-android-beta" target="_blank" rel="noopener">Update app</a>
|
||||
<a v-else class="gx-btn" :href="betaUrl" target="_blank" rel="noopener">Update app</a>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" aria-label="Dismiss app update notice" @click="dismissAppUpdate">Dismiss</button></template>
|
||||
</GxNotice>
|
||||
<GxState v-if="mode !== 'local'">Connect to your comma to set up remote access.</GxState>
|
||||
|
||||
@@ -61,7 +61,7 @@ export const SentryNotifications = {
|
||||
components: { GxNotice },
|
||||
name: "SentryNotifications",
|
||||
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
|
||||
data: () => ({ nativePush: !!window.StarPilotApp?.sentryPush, pushState: {}, testPoll: null, channels, labels, notifications: null, notificationBusy: false, notificationError: "",
|
||||
data: () => ({ nativePush: !!window.StarPilotApp?.sentryPush, pushState: {}, testPoll: null, relayUrl: '', channels, labels, notifications: null, notificationBusy: false, notificationError: "",
|
||||
drafts: Object.fromEntries(channels.map(name => [name, { url: "", token: "" }])) }),
|
||||
created() { this.client = new NotificationClient({ publish: update => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
|
||||
mounted() {
|
||||
@@ -83,6 +83,9 @@ export const SentryNotifications = {
|
||||
removePhone(id) { return this.client.run({ action: 'appUnsubscribe', id }) },
|
||||
simulate() { return this.client.run({ action: 'simulate' }) },
|
||||
canSimulate() { return !!window.StarPilotApp || location.protocol === 'http:' },
|
||||
async saveRelay() {
|
||||
if (await this.client.run({ action: 'configure', channel: 'appPush', enabled: true, url: this.relayUrl, token: '' })) this.relayUrl = ''
|
||||
},
|
||||
start() {
|
||||
clearInterval(this.poll)
|
||||
this.refreshPush()
|
||||
@@ -132,13 +135,15 @@ export const SentryNotifications = {
|
||||
<GxNotice tone="danger" v-if="notifications?.queueFull">The notification queue is full. Some events could not be queued.</GxNotice>
|
||||
<template v-if="notifications">
|
||||
<p class="gx-note">{{ notifications.deliverySemantics }}</p>
|
||||
<section v-if="nativePush || notifications.appSubscriptions?.length" class="gx-card gx-panel gx-stack">
|
||||
<h3>This phone (Galaxy app)</h3>
|
||||
<button v-if="nativePush" class="gx-btn" @click="togglePhone">{{ pushState.enabled ? 'Disable phone alerts' : 'Enable phone alerts' }}</button>
|
||||
<button v-if="pushState.registered && pushState.id" class="gx-btn" @click="testPhone(pushState.id)">Send test to this phone</button>
|
||||
<p v-if="!notifications.appPushAvailable" class="gx-note">Push not set up — the app will pick up real alerts by polling.</p>
|
||||
<section class="gx-card gx-panel gx-stack">
|
||||
<h3>Galaxy app phones</h3>
|
||||
<label>HTTPS push relay URL <input v-model="relayUrl" class="gx-field" type="password" autocomplete="off" placeholder="Leave blank to keep saved URL"></label>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="saveRelay">Save push relay</button>
|
||||
<p v-if="!notifications.appPushAvailable" class="gx-note">Configure a push relay to deliver phone alerts.</p>
|
||||
<button v-if="nativePush" type="button" class="gx-btn" :disabled="!notifications.appPushAvailable" @click="togglePhone">{{ pushState.enabled ? 'Disable phone alerts' : 'Enable phone alerts' }}</button>
|
||||
<button v-if="notifications.appPushAvailable && pushState.registered && pushState.id" type="button" class="gx-btn" @click="testPhone(pushState.id)">Send test to this phone</button>
|
||||
<p v-for="phone in (notifications.appSubscriptions || [])" :key="phone.id">{{ phone.label }} · {{ phone.lastResult || 'Not tested' }} · {{ phone.lastAt ? new Date(phone.lastAt * 1000).toLocaleString() : '' }}
|
||||
<button class="gx-btn" @click="testPhone(phone.id)">Test</button><button class="gx-btn" @click="removePhone(phone.id)">Remove</button>
|
||||
<button type="button" class="gx-btn" :disabled="!notifications.appPushAvailable" @click="testPhone(phone.id)">Test</button><button type="button" class="gx-btn" @click="removePhone(phone.id)">Remove</button>
|
||||
</p>
|
||||
<p v-for="phone in (notifications.removedAppSubscriptions || [])" :key="phone.id">{{ phone.lastResult }}</p>
|
||||
<button v-if="canSimulate()" class="gx-btn" @click="simulate">Simulate event</button>
|
||||
|
||||
@@ -25,7 +25,7 @@ from openpilot.starpilot.sentry_mode import web_push
|
||||
|
||||
CHANNELS = ('webPush', 'discord', 'webhook', 'ntfy', 'appPush')
|
||||
APP_PUSH_RELAY_URL = ''
|
||||
APP_IDS = ('link.firestar.companion', 'link.firestar.galaxy', 'link.galaxy.bluetooth.natehuby76')
|
||||
APP_IDS = ('com.starpilot.galaxy', 'link.firestar.companion', 'link.galaxy.bluetooth.natehuby76')
|
||||
MAX_STATE_BYTES = 1024 * 1024
|
||||
MAX_JOBS = 512
|
||||
MAX_AGE = 86400
|
||||
@@ -192,7 +192,8 @@ class NotificationOwner:
|
||||
type(key) is not str or len(key) != 64 or type(job['eventId']) is not str or ID.fullmatch(job['eventId']) is None or
|
||||
job['kind'] not in ('warning', 'alarm', 'test') or job['channel'] not in CHANNELS or
|
||||
type(job['generation']) is not str or ID.fullmatch(job['generation']) is None or
|
||||
(type(job['subscriptionId']) is not str or (job['subscriptionId'] and re.fullmatch(r'[0-9a-f]{12}' if job['channel'] == 'appPush' else r'[0-9a-f]{32}', job['subscriptionId']) is None)) or
|
||||
(type(job['subscriptionId']) is not str or (re.fullmatch(r'[0-9a-f]{12}' if job['channel'] == 'appPush' else
|
||||
r'[0-9a-f]{32}' if job['channel'] == 'webPush' else r'', job['subscriptionId']) is None)) or
|
||||
type(job['attempts']) is not int or not 0 <= job['attempts'] <= MAX_ATTEMPTS or
|
||||
job['state'] not in ('queued', 'sending', 'sent', 'failed', 'cancelled') or
|
||||
job['error'] not in ('', 'http', 'expired', 'timeout', 'transport', 'delivery_unknown', 'stale') or
|
||||
@@ -237,7 +238,7 @@ class NotificationOwner:
|
||||
'deliverySemantics': 'Retries may repeat delivery after an interrupted remote response.'}
|
||||
result['appSubscriptions'] = [{key: sub[key] for key in ('id', 'label', 'appId', 'addedAt', 'lastResult', 'lastAt')} for sub in self.state['appSubscriptions']]
|
||||
result['removedAppSubscriptions'] = [{'id': job['subscriptionId'], 'lastResult': 'Phone unregistered — removed', 'lastAt': job['next']} for job in jobs.values() if job['channel'] == 'appPush' and job['error'] == 'expired'][-8:]
|
||||
result['appPushAvailable'] = bool(self.state['channels']['appPush']['url'] or APP_PUSH_RELAY_URL)
|
||||
result['appPushAvailable'] = self.state['channels']['appPush']['enabled'] and bool(self.state['channels']['appPush']['url'] or APP_PUSH_RELAY_URL)
|
||||
for name, cfg in self.state['channels'].items():
|
||||
relevant = [j for j in jobs.values() if j['channel'] == name and j['generation'] == cfg['generation']]
|
||||
latest = max(relevant, key=lambda j: j['created'], default=None)
|
||||
@@ -281,7 +282,7 @@ class NotificationOwner:
|
||||
# Empty fields preserve configured credentials; explicit disable does not delete them.
|
||||
url = url or old['url']
|
||||
token = payload['token'] or old['token']
|
||||
if payload['enabled'] and name not in ('webPush', 'appPush') and not url:
|
||||
if payload['enabled'] and name != 'webPush' and not url:
|
||||
raise ValueError('Enter a notification URL')
|
||||
self.state['channels'][name] = {'enabled': payload['enabled'], 'url': url, 'token': token,
|
||||
'generation': uuid.uuid4().hex, 'since': now}
|
||||
@@ -319,7 +320,8 @@ class NotificationOwner:
|
||||
elif action == 'appSubscribe':
|
||||
if (set(payload) != {'action', 'token', 'appId', 'label'} or type(payload['token']) is not str or
|
||||
not 1 <= len(payload['token']) <= 4096 or any(ord(c) <= 32 for c in payload['token']) or
|
||||
payload['appId'] not in APP_IDS or type(payload['label']) is not str or len(payload['label']) > 64):
|
||||
type(payload['appId']) is not str or payload['appId'] not in APP_IDS or
|
||||
type(payload['label']) is not str or len(payload['label']) > 64):
|
||||
raise ValueError('Invalid app subscription')
|
||||
current = self.state['appSubscriptions']
|
||||
existing = next((sub for sub in current if sub['token'] == payload['token']), None)
|
||||
@@ -332,8 +334,7 @@ class NotificationOwner:
|
||||
else:
|
||||
raise ValueError('Remove a phone before adding another')
|
||||
cfg = self.state['channels']['appPush']
|
||||
cfg['enabled'] = True
|
||||
cfg['url'] = cfg['url'] or APP_PUSH_RELAY_URL
|
||||
cfg['enabled'] = bool(cfg['url'] or APP_PUSH_RELAY_URL)
|
||||
elif action == 'appUnsubscribe':
|
||||
if set(payload) not in ({'action', 'token'}, {'action', 'id'}):
|
||||
raise ValueError('Choose a phone subscription')
|
||||
|
||||
@@ -319,9 +319,11 @@ def test_app_subscription_dispatch_redaction_and_expired_removal(tmp_path):
|
||||
clock = [100.]
|
||||
owner = NotificationOwner(tmp_path / 'notifications', store(), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 410)
|
||||
try:
|
||||
snapshot = owner.action({'action': 'appSubscribe', 'token': 'private-fcm-token', 'appId': 'link.firestar.galaxy', 'label': 'Phone'})
|
||||
snapshot = owner.action({'action': 'appSubscribe', 'token': 'private-fcm-token', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'})
|
||||
sub_id = snapshot['appSubscriptions'][0]['id']
|
||||
assert len(sub_id) == 12 and 'private-fcm-token' not in json.dumps(snapshot)
|
||||
assert not snapshot['appPushAvailable']
|
||||
owner.action({'action': 'configure', 'channel': 'appPush', 'enabled': True, 'url': 'https://relay.example', 'token': ''})
|
||||
with pytest.raises(LookupError):
|
||||
owner.action({'action': 'test', 'target': 'app', 'id': 'missing'})
|
||||
owner.action({'action': 'test', 'target': 'app', 'id': sub_id})
|
||||
@@ -338,7 +340,8 @@ def test_app_success_dedupe_selfie_and_migration(tmp_path):
|
||||
events = []
|
||||
root = tmp_path / 'notifications'
|
||||
owner = NotificationOwner(root, store(events), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 200)
|
||||
owner.action({'action': 'appSubscribe', 'token': 'token', 'appId': 'link.firestar.companion', 'label': 'Phone'})
|
||||
owner.action({'action': 'appSubscribe', 'token': 'token', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'})
|
||||
owner.action({'action': 'configure', 'channel': 'appPush', 'enabled': True, 'url': 'https://relay.example', 'token': ''})
|
||||
events.extend([{'eventId': 'a' * 32, 'kind': 'selfie', 'wallTimeNs': int(101e9)}, {'eventId': 'b' * 32, 'kind': 'warning', 'wallTimeNs': int(101e9)}])
|
||||
clock[0] = 102.
|
||||
owner.tick(); owner.tick()
|
||||
@@ -356,6 +359,20 @@ def test_app_success_dedupe_selfie_and_migration(tmp_path):
|
||||
migrated.close()
|
||||
|
||||
|
||||
def test_webhook_job_survives_restart_with_empty_subscription_id(tmp_path):
|
||||
root = tmp_path / 'notifications'
|
||||
owner = NotificationOwner(root, store())
|
||||
configure(owner, 'webhook')
|
||||
owner.action({'action': 'test', 'channel': 'webhook'})
|
||||
owner.close()
|
||||
reopened = NotificationOwner(root, store())
|
||||
try:
|
||||
assert reopened.available
|
||||
assert reopened.snapshot()['channels']['webhook']['pending'] == 1
|
||||
finally:
|
||||
reopened.close()
|
||||
|
||||
|
||||
def test_ios_bundle_id_can_subscribe(tmp_path):
|
||||
owner = NotificationOwner(tmp_path / 'notifications', store(), transport=lambda *args: 200)
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user