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
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:
1 parent
f76b0f34ff
commit
5b636b66e2
9 files changed
+123
-14
No files matched your search
+7
-1
@@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.22.0] - 2026-10-31
|
||||||
|
|
||||||
### Added
|
### 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.
|
- 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.
|
- 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.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.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
|
[0.20.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.19.0...v0.20.0
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.22.0",
|
"version": "0.22.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.22.0",
|
"version": "0.22.1",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.22.0",
|
"version": "0.22.1",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Globe,
|
Globe,
|
||||||
Send,
|
Send,
|
||||||
|
X,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Palette,
|
Palette,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -50,7 +51,7 @@ import { SessionsPanel } from './SessionsPanel';
|
|||||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
|
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
|
||||||
import { isJournalEnabled, setJournalEnabled } from '../../lib/journal';
|
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';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
|
|
||||||
interface UserSettingsViewProps {
|
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
|
// Web push: devices of this account and whether this browser is one of them
|
||||||
const [pushDevices, setPushDevices] = useState<PushDevice[]>([]);
|
const [pushDevices, setPushDevices] = useState<PushDevice[]>([]);
|
||||||
const [pushHere, setPushHere] = useState(false);
|
const [pushHere, setPushHere] = useState(false);
|
||||||
|
const [pushEndpointHere, setPushEndpointHere] = useState<string | null>(null);
|
||||||
const [pushBusy, setPushBusy] = useState(false);
|
const [pushBusy, setPushBusy] = useState(false);
|
||||||
const [pushMsg, setPushMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
const [pushMsg, setPushMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||||
const reloadPush = useCallback(async () => {
|
const reloadPush = useCallback(async () => {
|
||||||
const [devices, sub] = await Promise.all([api.getPushDevices().catch(() => []), currentPushSubscription()]);
|
const [devices, sub] = await Promise.all([api.getPushDevices().catch(() => []), currentPushSubscription()]);
|
||||||
setPushDevices(devices);
|
setPushDevices(devices);
|
||||||
setPushHere(Boolean(sub && devices.some((d) => d.endpoint === sub.endpoint)));
|
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(() => {
|
useEffect(() => {
|
||||||
void reloadPush();
|
void reloadPush();
|
||||||
}, [reloadPush]);
|
}, [reloadPush]);
|
||||||
@@ -1783,10 +1799,36 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{pushDevices.length > 0 && (
|
{pushDevices.length > 0 && (
|
||||||
<p className="pl-6 text-[11px] text-faint">
|
<div className="pl-6 space-y-1">
|
||||||
Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}:{' '}
|
<p className="text-[11px] text-faint">
|
||||||
{pushDevices.map((d) => d.device || 'Gerät').join(', ')}
|
Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}. Alte Installationen
|
||||||
</p>
|
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 && (
|
{pushMsg && (
|
||||||
<p className={`pl-6 text-[11px] ${pushMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>{pushMsg.text}</p>
|
<p className={`pl-6 text-[11px] ${pushMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>{pushMsg.text}</p>
|
||||||
|
|||||||
@@ -734,8 +734,8 @@ export const api = {
|
|||||||
return res.devices || [];
|
return res.devices || [];
|
||||||
},
|
},
|
||||||
|
|
||||||
async subscribePush(sub: PushSubscriptionJSON, device: string): Promise<void> {
|
async subscribePush(sub: PushSubscriptionJSON, device: string, replaces?: string): Promise<void> {
|
||||||
await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device }) });
|
await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device, replaces: replaces || '' }) });
|
||||||
},
|
},
|
||||||
|
|
||||||
async unsubscribePush(endpoint: string): Promise<void> {
|
async unsubscribePush(endpoint: string): Promise<void> {
|
||||||
|
|||||||
@@ -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)');
|
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> {
|
export async function disablePush(): Promise<void> {
|
||||||
@@ -73,4 +106,5 @@ export async function disablePush(): Promise<void> {
|
|||||||
const endpoint = sub.endpoint;
|
const endpoint = sub.endpoint;
|
||||||
await sub.unsubscribe().catch(() => false);
|
await sub.unsubscribe().catch(() => false);
|
||||||
await api.unsubscribePush(endpoint).catch(() => {});
|
await api.unsubscribePush(endpoint).catch(() => {});
|
||||||
|
storeEndpoint(null);
|
||||||
}
|
}
|
||||||
@@ -148,6 +148,8 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
|
|||||||
Auth string `json:"auth"`
|
Auth string `json:"auth"`
|
||||||
} `json:"keys"`
|
} `json:"keys"`
|
||||||
Device string `json:"device"`
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
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 {
|
if len(device) > 120 {
|
||||||
device = 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")
|
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gespeichert werden")
|
||||||
return
|
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})
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,3 +77,24 @@ func TestPushDigestText(t *testing.T) {
|
|||||||
t.Fatalf("many: %+v", many)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user