feat(mobile): ios selfhost server support (#11563)

This commit is contained in:
CatsJuice
2025-04-14 01:13:23 +00:00
parent e1cb8198f1
commit d6b4ee8172
9 changed files with 363 additions and 73 deletions
@@ -41,8 +41,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark", "location" : "https://github.com/swiftlang/swift-cmark",
"state" : { "state" : {
"revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703",
"version" : "0.5.0" "version" : "0.6.0"
} }
}, },
{ {
@@ -41,8 +41,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark", "location" : "https://github.com/swiftlang/swift-cmark",
"state" : { "state" : {
"revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703",
"version" : "0.5.0" "version" : "0.6.0"
} }
}, },
{ {
+8 -8
View File
@@ -1,14 +1,14 @@
PODS: PODS:
- Capacitor (7.2.0): - Capacitor (7.2.0):
- CapacitorCordova - CapacitorCordova
- CapacitorApp (7.0.0): - CapacitorApp (7.0.1):
- Capacitor - Capacitor
- CapacitorBrowser (7.0.0): - CapacitorBrowser (7.0.1):
- Capacitor - Capacitor
- CapacitorCordova (7.2.0) - CapacitorCordova (7.2.0)
- CapacitorHaptics (7.0.0): - CapacitorHaptics (7.0.1):
- Capacitor - Capacitor
- CapacitorKeyboard (7.0.0): - CapacitorKeyboard (7.0.1):
- Capacitor - Capacitor
- CryptoSwift (1.8.3) - CryptoSwift (1.8.3)
@@ -41,11 +41,11 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
Capacitor: 106e7a4205f4618d582b886a975657c61179138d Capacitor: 106e7a4205f4618d582b886a975657c61179138d
CapacitorApp: 45cb7cbef4aa380b9236fd6980033eb5cde6fcd2 CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
CapacitorBrowser: 352a66541b15ceadae1d703802b11979023705e3 CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
CapacitorHaptics: 1fba3e460e7614349c6d5f868b1fccdc5c87b66d CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3
CapacitorKeyboard: 2c26c6fccde35023c579fc37d4cae6326d5e6343 CapacitorKeyboard: 969647d0ca2e5c737d7300088e2517aa832434e2
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483 CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
PODFILE CHECKSUM: bd61c17ff51f31ae55ec8dc579da83fda7bb51cb PODFILE CHECKSUM: bd61c17ff51f31ae55ec8dc579da83fda7bb51cb
+11 -1
View File
@@ -337,6 +337,7 @@ CapacitorApp.addListener('appUrlOpen', ({ url }) => {
if (urlObj.hostname === 'authentication') { if (urlObj.hostname === 'authentication') {
const method = urlObj.searchParams.get('method'); const method = urlObj.searchParams.get('method');
const payload = JSON.parse(urlObj.searchParams.get('payload') ?? 'false'); const payload = JSON.parse(urlObj.searchParams.get('payload') ?? 'false');
const serverBaseUrl = urlObj.searchParams.get('server');
if ( if (
!method || !method ||
@@ -347,9 +348,18 @@ CapacitorApp.addListener('appUrlOpen', ({ url }) => {
return; return;
} }
const authService = frameworkProvider let authService = frameworkProvider
.get(DefaultServerService) .get(DefaultServerService)
.server.scope.get(AuthService); .server.scope.get(AuthService);
if (serverBaseUrl) {
const serversService = frameworkProvider.get(ServersService);
const server = serversService.getServerByBaseUrl(serverBaseUrl);
if (server) {
authService = server.scope.get(AuthService);
}
}
if (method === 'oauth') { if (method === 'oauth') {
authService authService
.signInOauth(payload.code, payload.state, payload.provider) .signInOauth(payload.code, payload.state, payload.provider)
@@ -1,4 +1,4 @@
import { SignInPanel } from '@affine/core/components/sign-in'; import { SignInPanel, type SignInStep } from '@affine/core/components/sign-in';
import type { AuthSessionStatus } from '@affine/core/modules/cloud/entities/session'; import type { AuthSessionStatus } from '@affine/core/modules/cloud/entities/session';
import { useCallback } from 'react'; import { useCallback } from 'react';
@@ -7,9 +7,11 @@ import { MobileSignInLayout } from './layout';
export const MobileSignInPanel = ({ export const MobileSignInPanel = ({
onClose, onClose,
server, server,
initStep,
}: { }: {
onClose: () => void; onClose: () => void;
server?: string; server?: string;
initStep?: SignInStep;
}) => { }) => {
const onAuthenticated = useCallback( const onAuthenticated = useCallback(
(status: AuthSessionStatus) => { (status: AuthSessionStatus) => {
@@ -26,6 +28,7 @@ export const MobileSignInPanel = ({
onSkip={onClose} onSkip={onClose}
onAuthenticated={onAuthenticated} onAuthenticated={onAuthenticated}
server={server} server={server}
initStep={initStep}
/> />
</MobileSignInLayout> </MobileSignInLayout>
); );
@@ -19,6 +19,7 @@ export const divider = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
position: 'relative', position: 'relative',
flexShrink: 0,
':before': { ':before': {
content: '""', content: '""',
width: '100%', width: '100%',
@@ -38,6 +39,11 @@ export const head = style([
color: cssVarV2('text/primary'), color: cssVarV2('text/primary'),
}, },
]); ]);
export const headActions = style({
display: 'flex',
alignItems: 'center',
gap: 14,
});
export const body = style({ export const body = style({
overflowY: 'auto', overflowY: 'auto',
flexShrink: 0, flexShrink: 0,
@@ -79,3 +85,30 @@ export const wsName = style([
textAlign: 'left', textAlign: 'left',
}, },
]); ]);
export const serverInfo = style({
padding: '6px 20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
});
export const serverName = style([
footnoteRegular,
{
color: cssVarV2.text.secondary,
flexShrink: 0,
},
]);
export const serverAccount = style([
serverName,
{
flexShrink: 1,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
},
]);
export const spaceX = style({
width: 0,
flex: 1,
});
@@ -1,26 +1,37 @@
import { IconButton } from '@affine/component'; import { Divider, IconButton, Menu, MenuItem } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info'; import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
import { WorkspaceAvatar } from '@affine/core/components/workspace-avatar'; import { WorkspaceAvatar } from '@affine/core/components/workspace-avatar';
import {
type AuthAccountInfo,
AuthService,
type Server,
ServersService,
} from '@affine/core/modules/cloud';
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { import {
type WorkspaceMetadata, type WorkspaceMetadata,
WorkspaceService,
WorkspacesService, WorkspacesService,
} from '@affine/core/modules/workspace'; } from '@affine/core/modules/workspace';
import { CloseIcon, CollaborationIcon } from '@blocksuite/icons/rc'; import { useI18n } from '@affine/i18n';
import { import {
useLiveData, AccountIcon,
useService, CloseIcon,
useServiceOptional, CollaborationIcon,
} from '@toeverything/infra'; DeleteIcon,
MoreHorizontalIcon,
SelfhostIcon,
SignOutIcon,
} from '@blocksuite/icons/rc';
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { type HTMLAttributes, useCallback, useMemo } from 'react'; import { type HTMLAttributes, useCallback, useMemo } from 'react';
import * as styles from './menu.css'; import * as styles from './menu.css';
const filterByFlavour = (workspaces: WorkspaceMetadata[], flavour: string) =>
workspaces.filter(ws => flavour === ws.flavour);
const WorkspaceItem = ({ const WorkspaceItem = ({
workspace, workspace,
className, className,
@@ -49,82 +60,306 @@ const WorkspaceItem = ({
); );
}; };
const WorkspaceList = ({ interface WorkspaceListProps {
list, items: WorkspaceMetadata[];
title, onClick: (workspace: WorkspaceMetadata) => void;
onClose, onSettingClick?: (workspace: WorkspaceMetadata) => void;
}: { onEnableCloudClick?: (meta: WorkspaceMetadata) => void;
title: string; }
list: WorkspaceMetadata[]; export const WorkspaceList = (props: WorkspaceListProps) => {
onClose?: () => void; const workspaceList = props.items;
}) => {
const currentWorkspace = useServiceOptional(WorkspaceService)?.workspace;
const { jumpToPage } = useNavigateHelper(); return workspaceList.map(item => (
const toggleWorkspace = useCallback( <WorkspaceItem
(id: string) => { key={item.id}
if (id !== currentWorkspace?.id) { workspace={item}
jumpToPage(id, 'home'); onClick={() => props.onClick(item)}
} />
onClose?.(); ));
}, };
[currentWorkspace?.id, jumpToPage, onClose]
const WorkspaceServerInfo = ({
server,
name,
account,
accountStatus,
onDeleteServer,
onSignOut,
onSignIn,
}: {
server: string;
name: string;
account?: AuthAccountInfo | null;
accountStatus?: 'authenticated' | 'unauthenticated';
onDeleteServer?: () => void;
onSignOut?: () => void;
onSignIn?: () => void;
}) => {
const t = useI18n();
const isCloud = server !== 'local';
const menuItems = useMemo(
() =>
[
server !== 'affine-cloud' && server !== 'local' && (
<MenuItem
prefixIcon={<DeleteIcon />}
type="danger"
key="delete-server"
onClick={onDeleteServer}
>
{t['com.affine.server.delete']()}
</MenuItem>
),
accountStatus === 'authenticated' && (
<MenuItem
prefixIcon={<SignOutIcon />}
key="sign-out"
onClick={onSignOut}
type="danger"
>
{t['Sign out']()}
</MenuItem>
),
accountStatus === 'unauthenticated' && (
<MenuItem
prefixIcon={<AccountIcon />}
key="sign-in"
onClick={onSignIn}
>
{t['Sign in']()}
</MenuItem>
),
].filter(Boolean),
[accountStatus, onDeleteServer, onSignIn, onSignOut, server, t]
); );
if (!list.length) return null; return (
<div className={styles.serverInfo}>
<div className={styles.serverName}>{name}</div>
{isCloud ? (
<div className={styles.serverAccount}>
- {account ? account.email : 'Not signed in'}
</div>
) : null}
<div className={styles.spaceX} />
{menuItems.length ? (
<Menu items={menuItems}>
<IconButton icon={<MoreHorizontalIcon />} />
</Menu>
) : null}
</div>
);
};
const LocalWorkspaces = ({
workspaces,
onClickWorkspace,
onClickWorkspaceSetting,
onClickEnableCloud,
}: {
workspaces: WorkspaceMetadata[];
onClickWorkspace: (workspaceMetadata: WorkspaceMetadata) => void;
onClickWorkspaceSetting?: (workspaceMetadata: WorkspaceMetadata) => void;
onClickEnableCloud?: (meta: WorkspaceMetadata) => void;
}) => {
const t = useI18n();
if (workspaces.length === 0) {
return null;
}
return (
<>
<WorkspaceServerInfo
server="local"
name={t['com.affine.workspaceList.workspaceListType.local']()}
/>
<WorkspaceList
items={workspaces}
onClick={onClickWorkspace}
onSettingClick={onClickWorkspaceSetting}
onEnableCloudClick={onClickEnableCloud}
/>
</>
);
};
const CloudWorkSpaceList = ({
server,
workspaces,
onClickWorkspace,
onClickEnableCloud,
}: {
server: Server;
workspaces: WorkspaceMetadata[];
onClickWorkspace: (workspaceMetadata: WorkspaceMetadata) => void;
onClickEnableCloud?: (meta: WorkspaceMetadata) => void;
}) => {
const globalContextService = useService(GlobalContextService);
const globalDialogService = useService(GlobalDialogService);
const serverName = useLiveData(server.config$.selector(c => c.serverName));
const authService = useService(AuthService);
const serversService = useService(ServersService);
const account = useLiveData(authService.session.account$);
const accountStatus = useLiveData(authService.session.status$);
const navigateHelper = useNavigateHelper();
const currentWorkspaceFlavour = useLiveData(
globalContextService.globalContext.workspaceFlavour.$
);
const handleDeleteServer = useCallback(() => {
serversService.removeServer(server.id);
if (currentWorkspaceFlavour === server.id) {
const otherWorkspace = workspaces.find(w => w.flavour !== server.id);
if (otherWorkspace) {
navigateHelper.openPage(otherWorkspace.id, 'all');
}
}
}, [
currentWorkspaceFlavour,
navigateHelper,
server.id,
serversService,
workspaces,
]);
const handleSignOut = useAsyncCallback(async () => {
await authService.signOut();
}, [authService]);
const handleSignIn = useAsyncCallback(async () => {
globalDialogService.open('sign-in', {
server: server.baseUrl,
});
}, [globalDialogService, server.baseUrl]);
return ( return (
<> <>
<section className={styles.wsListTitle}>{title}</section> <WorkspaceServerInfo
<ul className={styles.wsList}> server={server.id}
{list.map(ws => ( name={serverName}
<WorkspaceItem account={account}
key={ws.id} accountStatus={accountStatus}
workspace={ws} onDeleteServer={handleDeleteServer}
onClick={() => toggleWorkspace?.(ws.id)} onSignOut={handleSignOut}
/> onSignIn={handleSignIn}
))} />
</ul> <WorkspaceList
items={workspaces}
onClick={onClickWorkspace}
onEnableCloudClick={onClickEnableCloud}
/>
</> </>
); );
}; };
const AddServer = () => {
const globalDialogService = useService(GlobalDialogService);
const featureFlagService = useService(FeatureFlagService);
const enableMultipleServer = useLiveData(
featureFlagService.flags.enable_multiple_cloud_servers.$
);
const onAddServer = useCallback(() => {
globalDialogService.open('sign-in', { step: 'addSelfhosted' });
}, [globalDialogService]);
if (!enableMultipleServer) {
return null;
}
return <IconButton onClick={onAddServer} size="24" icon={<SelfhostIcon />} />;
};
export const SelectorMenu = ({ onClose }: { onClose?: () => void }) => { export const SelectorMenu = ({ onClose }: { onClose?: () => void }) => {
const workspacesService = useService(WorkspacesService); const workspacesService = useService(WorkspacesService);
const workspaces = useLiveData(workspacesService.list.workspaces$); const workspaces = useLiveData(workspacesService.list.workspaces$);
const serversService = useService(ServersService);
const { jumpToPage } = useNavigateHelper();
const servers = useLiveData(serversService.servers$);
const affineCloudServer = useMemo(
() => servers.find(s => s.id === 'affine-cloud') as Server,
[servers]
);
const selfhostServers = useMemo(
() => servers.filter(s => s.id !== 'affine-cloud'),
[servers]
);
// TODO: support selfhosted
const cloudWorkspaces = useMemo( const cloudWorkspaces = useMemo(
() => filterByFlavour(workspaces, 'affine-cloud'), () =>
workspaces.filter(
({ flavour }) => flavour !== 'local'
) as WorkspaceMetadata[],
[workspaces] [workspaces]
); );
const localWorkspaces = useMemo( const localWorkspaces = useMemo(
() => filterByFlavour(workspaces, 'local'), () =>
workspaces.filter(
({ flavour }) => flavour === 'local'
) as WorkspaceMetadata[],
[workspaces] [workspaces]
); );
const handleClickWorkspace = useCallback(
(workspaceMetadata: WorkspaceMetadata) => {
const id = workspaceMetadata.id;
if (id !== currentWorkspace?.id) {
jumpToPage(id, 'home');
}
onClose?.();
},
[onClose, jumpToPage]
);
return ( return (
<div className={styles.root}> <div className={styles.root}>
<header className={styles.head}> <header className={styles.head}>
Workspace Workspace
<IconButton onClick={onClose} size="24" icon={<CloseIcon />} /> <div className={styles.headActions}>
<AddServer />
<IconButton onClick={onClose} size="24" icon={<CloseIcon />} />
</div>
</header> </header>
<div className={styles.divider} /> <div className={styles.divider} />
<main className={styles.body}> <main className={styles.body}>
<WorkspaceList {/* 1. affine-cloud */}
onClose={onClose} <FrameworkScope
title="Cloud Sync" key={affineCloudServer.id}
list={cloudWorkspaces} scope={affineCloudServer.scope}
/> >
{cloudWorkspaces.length && localWorkspaces.length ? ( <CloudWorkSpaceList
<div className={styles.divider} /> server={affineCloudServer}
) : null} workspaces={cloudWorkspaces.filter(
<WorkspaceList ({ flavour }) => flavour === affineCloudServer.id
onClose={onClose} )}
title="Local Storage" onClickWorkspace={handleClickWorkspace}
list={localWorkspaces} />
</FrameworkScope>
{(localWorkspaces.length > 0 || selfhostServers.length > 0) && (
<Divider size="thinner" />
)}
<LocalWorkspaces
workspaces={localWorkspaces}
onClickWorkspace={handleClickWorkspace}
/> />
{selfhostServers.length > 0 && <Divider size="thinner" />}
{/* 3. selfhost */}
{selfhostServers.map((server, index) => (
<FrameworkScope key={server.id} scope={server.scope}>
<CloudWorkSpaceList
server={server}
workspaces={cloudWorkspaces.filter(
({ flavour }) => flavour === server.id
)}
onClickWorkspace={handleClickWorkspace}
/>
{index !== selfhostServers.length - 1 && <Divider size="thinner" />}
</FrameworkScope>
))}
</main> </main>
</div> </div>
); );
@@ -1,4 +1,5 @@
import { IconButton, Modal, SafeArea } from '@affine/component'; import { IconButton, Modal, SafeArea } from '@affine/component';
import type { SignInStep } from '@affine/core/components/sign-in';
import type { import type {
DialogComponentProps, DialogComponentProps,
GLOBAL_DIALOG_SCHEMA, GLOBAL_DIALOG_SCHEMA,
@@ -11,6 +12,7 @@ import { MobileSignInPanel } from '../../components/sign-in';
export const SignInDialog = ({ export const SignInDialog = ({
close, close,
server: initialServerBaseUrl, server: initialServerBaseUrl,
step,
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['sign-in']>) => { }: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['sign-in']>) => {
return ( return (
<Modal <Modal
@@ -27,7 +29,11 @@ export const SignInDialog = ({
}} }}
withoutCloseButton withoutCloseButton
> >
<MobileSignInPanel onClose={close} server={initialServerBaseUrl} /> <MobileSignInPanel
onClose={close}
server={initialServerBaseUrl}
initStep={step as SignInStep}
/>
<SafeArea <SafeArea
top top
style={{ position: 'absolute', top: 0, right: 0, paddingRight: 16 }} style={{ position: 'absolute', top: 0, right: 0, paddingRight: 16 }}
@@ -38,7 +44,10 @@ export const SignInDialog = ({
variant="solid" variant="solid"
icon={<CloseIcon />} icon={<CloseIcon />}
style={{ borderRadius: 8, padding: 4 }} style={{ borderRadius: 8, padding: 4 }}
onClick={() => close()} onClick={e => {
e.stopPropagation();
close();
}}
/> />
</SafeArea> </SafeArea>
</Modal> </Modal>
@@ -195,7 +195,7 @@ export const AFFINE_FLAGS = {
description: description:
'com.affine.settings.workspace.experimental-features.enable-multiple-cloud-servers.description', 'com.affine.settings.workspace.experimental-features.enable-multiple-cloud-servers.description',
configurable: false, configurable: false,
defaultState: isDesktopEnvironment, defaultState: isDesktopEnvironment || BUILD_CONFIG.isIOS,
}, },
enable_mobile_edgeless_editing: { enable_mobile_edgeless_editing: {
category: 'affine', category: 'affine',