Enable dual-device login and mobile APK update checks

This commit is contained in:
yoyuzh
2026-04-03 16:28:09 +08:00
parent 56f2a9fe0d
commit 52b5bbfe8e
50 changed files with 1659 additions and 164 deletions

View File

@@ -182,6 +182,7 @@ test('apiRequest attaches bearer token and unwraps response payload', async () =
assert.deepEqual(payload, {ok: true});
assert.ok(request instanceof Request);
assert.equal(request.headers.get('Authorization'), 'Bearer token-123');
assert.equal(request.headers.get('X-Yoyuzh-Client'), 'desktop');
assert.equal(request.url, 'http://localhost/api/files/recent');
});

View File

@@ -1,4 +1,5 @@
import type { AuthResponse } from './types';
import { PORTAL_CLIENT_HEADER, resolvePortalClientType } from './app-shell';
import { clearStoredSession, createSession, readStoredSession, saveStoredSession } from './session';
interface ApiEnvelope<T> {
@@ -148,6 +149,10 @@ function normalizePath(path: string) {
return path.startsWith('/') ? path : `/${path}`;
}
function shouldAttachPortalClientHeader(path: string) {
return !/^https?:\/\//.test(path);
}
function shouldAttemptTokenRefresh(path: string) {
const normalizedPath = normalizePath(path);
return ![
@@ -189,12 +194,15 @@ async function refreshAccessToken() {
refreshRequestPromise = (async () => {
try {
const headers = new Headers({
Accept: 'application/json',
'Content-Type': 'application/json',
});
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
const response = await fetch(resolveUrl(AUTH_REFRESH_PATH), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
headers,
body: JSON.stringify({
refreshToken: currentSession.refreshToken,
}),
@@ -269,6 +277,9 @@ async function performRequest(path: string, init: ApiRequestInit = {}, allowRefr
if (session?.token) {
headers.set('Authorization', `Bearer ${session.token}`);
}
if (shouldAttachPortalClientHeader(path) && !headers.has(PORTAL_CLIENT_HEADER)) {
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
}
if (requestBody && !(requestBody instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
@@ -341,6 +352,9 @@ function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, a
if (session?.token) {
headers.set('Authorization', `Bearer ${session.token}`);
}
if (shouldAttachPortalClientHeader(path) && !headers.has(PORTAL_CLIENT_HEADER)) {
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
}
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}

View File

@@ -1,10 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MOBILE_APP_MAX_WIDTH, shouldUseMobileApp } from './app-shell';
import {
MOBILE_APP_MAX_WIDTH,
isNativeAppShellLocation,
resolvePortalClientType,
shouldUseMobileApp,
} from './app-shell';
test('shouldUseMobileApp enables the mobile shell below the width breakpoint', () => {
assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH - 1), true);
assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH), false);
assert.equal(shouldUseMobileApp(1280), false);
});
test('isNativeAppShellLocation matches Capacitor localhost origins', () => {
assert.equal(isNativeAppShellLocation(new URL('https://localhost')), true);
assert.equal(isNativeAppShellLocation(new URL('http://127.0.0.1')), true);
assert.equal(isNativeAppShellLocation(new URL('capacitor://localhost')), true);
assert.equal(isNativeAppShellLocation(new URL('http://localhost:3000')), false);
assert.equal(isNativeAppShellLocation(new URL('https://yoyuzh.xyz')), false);
});
test('resolvePortalClientType distinguishes desktop web from mobile shell or narrow screens', () => {
assert.equal(resolvePortalClientType({ location: new URL('https://yoyuzh.xyz'), viewportWidth: 1280 }), 'desktop');
assert.equal(resolvePortalClientType({ location: new URL('https://yoyuzh.xyz'), viewportWidth: 390 }), 'mobile');
assert.equal(resolvePortalClientType({ location: new URL('https://localhost') }), 'mobile');
});

View File

@@ -1,5 +1,69 @@
export const MOBILE_APP_MAX_WIDTH = 768;
export const PORTAL_CLIENT_HEADER = 'X-Yoyuzh-Client';
export type PortalClientType = 'desktop' | 'mobile';
export function shouldUseMobileApp(width: number) {
return width < MOBILE_APP_MAX_WIDTH;
}
export function isNativeAppShellLocation(location: Location | URL | null) {
if (!location) {
return false;
}
const hostname = location.hostname || '';
const protocol = location.protocol || '';
const port = location.port || '';
if (protocol === 'capacitor:') {
return true;
}
const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1';
const isCapacitorLocalScheme = protocol === 'http:' || protocol === 'https:';
return isLocalhostHost && isCapacitorLocalScheme && port === '';
}
function resolveRuntimeViewportWidth() {
if (typeof globalThis.innerWidth === 'number' && Number.isFinite(globalThis.innerWidth)) {
return globalThis.innerWidth;
}
if (typeof window !== 'undefined' && typeof window.innerWidth === 'number') {
return window.innerWidth;
}
return null;
}
function resolveRuntimeLocation() {
if (typeof globalThis.location !== 'undefined') {
return globalThis.location;
}
if (typeof window !== 'undefined') {
return window.location;
}
return null;
}
export function resolvePortalClientType({
location = resolveRuntimeLocation(),
viewportWidth = resolveRuntimeViewportWidth(),
}: {
location?: Location | URL | null;
viewportWidth?: number | null;
} = {}): PortalClientType {
if (isNativeAppShellLocation(location)) {
return 'mobile';
}
if (typeof viewportWidth === 'number' && shouldUseMobileApp(viewportWidth)) {
return 'mobile';
}
return 'desktop';
}

View File

@@ -106,6 +106,18 @@ export interface FileMetadata {
createdAt: string;
}
export interface RecycleBinItem {
id: number;
filename: string;
path: string;
size: number;
contentType: string | null;
directory: boolean;
createdAt: string;
deletedAt: string;
expiresAt: string;
}
export interface InitiateUploadResponse {
direct: boolean;
uploadUrl: string;