Enable dual-device login and mobile APK update checks
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user