fix(push): reinstalled app replaces its old subscription; devices listed and removable
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 2m8s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-31 11:59:40 +01:00
1 parent f76b0f34ff
commit 5b636b66e2
9 files changed
+123 -14

No files matched your search

+7 -1
View File
@@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
## [0.22.1] - 2026-10-31
### Fixed
- Push-Benachrichtigungen: Nach dem Neuinstallieren der App zählte die alte Installation als weiteres Gerät mit („2 aktiv“), bis der nächste Push sie aussortierte. Schaltet ein Gerät Push neu ein, meldet es jetzt seine alte Anmeldung mit ab. Außerdem zeigen die Einstellungen alle Geräte einzeln (mit Datum und „dieses Gerät“) und jedes lässt sich entfernen.
## [0.22.0] - 2026-10-31
### Added
@@ -459,7 +464,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.0...dev
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.1...dev
[0.22.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.0...v0.22.1
[0.22.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...v0.22.0
[0.21.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.20.0...v0.21.0
[0.20.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.19.0...v0.20.0
+1 -1
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Version](https://img.shields.io/badge/Version-v0.22.0-16a34a?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-v0.22.1-16a34a?style=flat-square)](CHANGELOG.md)
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.22.0",
"version": "0.22.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.22.0",
"version": "0.22.1",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.22.0",
"version": "0.22.1",
"license": "Unlicense",
"type": "module",
"scripts": {
@@ -11,6 +11,7 @@ import {
MessageSquare,
Globe,
Send,
X,
AlertCircle,
Palette,
Upload,
@@ -50,7 +51,7 @@ import { SessionsPanel } from './SessionsPanel';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { isJournalEnabled, setJournalEnabled } from '../../lib/journal';
import { currentPushSubscription, disablePush, enablePush, pushSupported } from '../../lib/push';
import { currentPushSubscription, disablePush, enablePush, pushSupported, removePushDevice } from '../../lib/push';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps {
@@ -301,13 +302,28 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
// Web push: devices of this account and whether this browser is one of them
const [pushDevices, setPushDevices] = useState<PushDevice[]>([]);
const [pushHere, setPushHere] = useState(false);
const [pushEndpointHere, setPushEndpointHere] = useState<string | null>(null);
const [pushBusy, setPushBusy] = useState(false);
const [pushMsg, setPushMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const reloadPush = useCallback(async () => {
const [devices, sub] = await Promise.all([api.getPushDevices().catch(() => []), currentPushSubscription()]);
setPushDevices(devices);
setPushHere(Boolean(sub && devices.some((d) => d.endpoint === sub.endpoint)));
setPushEndpointHere(sub?.endpoint ?? null);
}, []);
// An old installation or a device you no longer use: remove it from the list
const removeDevice = async (endpoint: string) => {
setPushBusy(true);
setPushMsg(null);
try {
await removePushDevice(endpoint);
await reloadPush();
} catch (err) {
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Gerät konnte nicht entfernt werden' });
} finally {
setPushBusy(false);
}
};
useEffect(() => {
void reloadPush();
}, [reloadPush]);
@@ -1783,10 +1799,36 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
</div>
{pushDevices.length > 0 && (
<p className="pl-6 text-[11px] text-faint">
Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}:{' '}
{pushDevices.map((d) => d.device || 'Gerät').join(', ')}
</p>
<div className="pl-6 space-y-1">
<p className="text-[11px] text-faint">
Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}. Alte Installationen
verschwinden beim nächsten Push von selbst oder lassen sich hier entfernen.
</p>
<ul className="border border-border divide-y divide-border bg-panel">
{pushDevices.map((d) => (
<li key={d.id} className="flex items-center justify-between gap-2 pl-2.5 pr-1 py-1">
<span className="min-w-0 text-[11px] text-main truncate">
{d.device || 'Gerät'}
<span className="text-faint">
{' · seit '}
{new Date(d.createdAt).toLocaleDateString('de-DE')}
{d.endpoint === pushEndpointHere && ' · dieses Gerät'}
</span>
</span>
<button
type="button"
onClick={() => void removeDevice(d.endpoint)}
disabled={pushBusy || demoMode}
title="Gerät entfernen"
aria-label={`${d.device || 'Gerät'} entfernen`}
className="h-9 w-9 sm:h-7 sm:w-7 shrink-0 flex items-center justify-center text-faint hover:text-status-err disabled:opacity-40"
>
<X className="w-3.5 h-3.5" />
</button>
</li>
))}
</ul>
</div>
)}
{pushMsg && (
<p className={`pl-6 text-[11px] ${pushMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>{pushMsg.text}</p>
+2 -2
View File
@@ -734,8 +734,8 @@ export const api = {
return res.devices || [];
},
async subscribePush(sub: PushSubscriptionJSON, device: string): Promise<void> {
await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device }) });
async subscribePush(sub: PushSubscriptionJSON, device: string, replaces?: string): Promise<void> {
await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device, replaces: replaces || '' }) });
},
async unsubscribePush(endpoint: string): Promise<void> {
+35 -1
View File
@@ -64,7 +64,40 @@ export async function enablePush(): Promise<void> {
throw new Error('Der Browser hat die Anmeldung beim Push-Dienst abgelehnt (Berechtigung blockiert oder Push-Dienst nicht erreichbar)');
}
}
await api.subscribePush(sub.toJSON(), deviceName());
// The address this browser registered before is dead once it has a new one (e.g. after reinstalling
// the app): the server drops it right away instead of counting it as another device
const previous = readStoredEndpoint();
await api.subscribePush(sub.toJSON(), deviceName(), previous && previous !== sub.endpoint ? previous : undefined);
storeEndpoint(sub.endpoint);
}
const ENDPOINT_KEY = 'mindsift_push_endpoint';
function readStoredEndpoint(): string | null {
try {
return localStorage.getItem(ENDPOINT_KEY);
} catch {
return null;
}
}
function storeEndpoint(endpoint: string | null): void {
try {
if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint);
else localStorage.removeItem(ENDPOINT_KEY);
} catch {
// storage blocked: the dead device is removed on the next push instead
}
}
/** Removes one of the own devices (e.g. an old installation) on the server. */
export async function removePushDevice(endpoint: string): Promise<void> {
const here = await currentPushSubscription();
if (here?.endpoint === endpoint) {
await disablePush();
return;
}
await api.unsubscribePush(endpoint);
}
export async function disablePush(): Promise<void> {
@@ -73,4 +106,5 @@ export async function disablePush(): Promise<void> {
const endpoint = sub.endpoint;
await sub.unsubscribe().catch(() => false);
await api.unsubscribePush(endpoint).catch(() => {});
storeEndpoint(null);
}
+7 -1
View File
@@ -148,6 +148,8 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
Auth string `json:"auth"`
} `json:"keys"`
Device string `json:"device"`
// The subscription this browser had before (e.g. before a reinstall): it is dead now
Replaces string `json:"replaces"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
@@ -165,10 +167,14 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
if len(device) > 120 {
device = device[:120]
}
if err := s.store.SavePushSubscription(auth.GetUserID(r.Context()), req.Endpoint, req.Keys.P256dh, req.Keys.Auth, device); err != nil {
userID := auth.GetUserID(r.Context())
if err := s.store.SavePushSubscription(userID, req.Endpoint, req.Keys.P256dh, req.Keys.Auth, device); err != nil {
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gespeichert werden")
return
}
if req.Replaces != "" && req.Replaces != req.Endpoint {
_ = s.store.DeletePushSubscription(userID, req.Replaces) // only ever one of the own devices
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
+21
View File
@@ -77,3 +77,24 @@ func TestPushDigestText(t *testing.T) {
t.Fatalf("many: %+v", many)
}
}
// A reinstalled app registers a new subscription and names the old one: it is dropped, not counted twice
func TestPushResubscribeReplacesOldDevice(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
me, token := newTestUser(t, store, "reinstall@example.com")
sub := func(endpoint, replaces string) int {
body := `{"endpoint":"` + endpoint + `","keys":{"p256dh":"k","auth":"a"},"device":"Chrome auf Android","replaces":"` + replaces + `"}`
return sessionCall(router, http.MethodPost, "/api/v1/push/subscribe", token, body, "").Code
}
if code := sub("https://fcm.googleapis.com/fcm/send/alt", ""); code != http.StatusOK {
t.Fatalf("subscribe: %d", code)
}
if code := sub("https://fcm.googleapis.com/fcm/send/neu", "https://fcm.googleapis.com/fcm/send/alt"); code != http.StatusOK {
t.Fatalf("resubscribe: %d", code)
}
subs, _ := store.PushSubscriptions(me.ID)
if len(subs) != 1 || subs[0].Endpoint != "https://fcm.googleapis.com/fcm/send/neu" {
t.Fatalf("old installation must be replaced, got %+v", subs)
}
}