diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000000..5f1e0bcfcb --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,55 @@ +name: iOS Build + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +jobs: + build-ios: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + + - run: npm ci + + - run: npx tauri ios init + + - name: Build unsigned iOS .app + run: | + npx tauri ios build -- \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" + + - name: Package .ipa + run: | + APP=$(find src-tauri/gen/apple/build -name "*.app" -type d | head -1) + if [ -z "$APP" ]; then + echo "No .app found" + exit 1 + fi + mkdir -p Payload + cp -r "$APP" Payload/ + zip -qr nullptr-unsigned.ipa Payload + rm -rf Payload + + - uses: actions/upload-artifact@v4 + with: + name: nullptr-ios-unsigned + path: nullptr-unsigned.ipa + diff --git a/scripts/build-release.sh b/scripts/build-release.sh index f0dacb2c60..f38bb72325 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -26,8 +26,9 @@ Usage: $(basename "$0") [OPTIONS] [VERSION] Build nullptr release artifacts for all platforms. Options: - --desktop-only Skip Android build - --android-only Skip desktop build + --desktop-only Skip Android and iOS builds + --android-only Skip desktop and iOS builds + --ios-only Skip desktop and Android builds --target TARGET Build only a specific Rust target (e.g. x86_64-unknown-linux-gnu) --sign Sign Android APK (requires ANDROID_SIGNING_* env vars) --clean Clean Rust target directory before building @@ -43,6 +44,7 @@ Examples: ./scripts/build-release.sh # Build everything ./scripts/build-release.sh 1.0.0 # Build with version 1.0.0 ./scripts/build-release.sh --desktop-only # Desktop only + ./scripts/build-release.sh --ios-only # iOS only (requires macOS + Xcode) ./scripts/build-release.sh --target x86_64-unknown-linux-gnu EOF exit 0 @@ -50,6 +52,7 @@ EOF DESKTOP=true ANDROID=true +IOS=true SPECIFIC_TARGET="" SIGN_ANDROID=true CLEAN=false @@ -57,8 +60,9 @@ VERSION="" while [[ $# -gt 0 ]]; do case "$1" in - --desktop-only) ANDROID=false; shift ;; - --android-only) DESKTOP=false; shift ;; + --desktop-only) ANDROID=false; IOS=false; shift ;; + --android-only) DESKTOP=false; IOS=false; shift ;; + --ios-only) DESKTOP=false; ANDROID=false; shift ;; --target) SPECIFIC_TARGET="$2"; shift 2 ;; --sign) SIGN_ANDROID=true; shift ;; --clean) CLEAN=true; shift ;; @@ -229,6 +233,59 @@ if [[ "$ANDROID" == true ]]; then echo "" fi +# ── iOS build ── + +if [[ "$IOS" == true ]]; then + log "═══ iOS Build ═══" + echo "" + + if [[ "$(uname -s)" != "Darwin" ]]; then + warn "iOS builds require macOS with Xcode — skipping" + IOS=false + elif ! command -v xcodebuild &>/dev/null; then + warn "Xcode not found — skipping iOS build" + IOS=false + fi + + if [[ "$IOS" == true ]]; then + if ! rustup target list --installed | grep -q "aarch64-apple-ios"; then + rustup target add aarch64-apple-ios + fi + + if [[ ! -d "src-tauri/gen/apple" ]]; then + log "Initializing Tauri iOS project..." + npx tauri ios init + fi + + log "Building unsigned iOS .app (development export)..." + npx tauri ios build -- -allowProvisioningUpdates \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + 2>&1 | tail -10 + + APP_DIR="src-tauri/gen/apple/build" + if [[ -d "$APP_DIR" ]]; then + mkdir -p "$OUTPUT_DIR/ios" + find "$APP_DIR" -name "*.app" -type d | while read -r app; do + app_name="$(basename "$app")" + cp -r "$app" "$OUTPUT_DIR/ios/$app_name" + + # Package into an unsigned .ipa for distribution + PAYLOAD_DIR=$(mktemp -d) + mkdir -p "$PAYLOAD_DIR/Payload" + cp -r "$app" "$PAYLOAD_DIR/Payload/" + (cd "$PAYLOAD_DIR" && zip -qr "${OLDPWD}/${OUTPUT_DIR}/ios/nullptr-${VERSION}-unsigned.ipa" Payload) + rm -rf "$PAYLOAD_DIR" + done + ok "iOS .app → ${OUTPUT_DIR}/ios/" + else + warn "No iOS build output found" + fi + fi + echo "" +fi + # ── Summary ── echo "" @@ -241,7 +298,7 @@ echo "" if [[ -d "$OUTPUT_DIR" ]]; then find "$OUTPUT_DIR" -type f \( -name "*.deb" -o -name "*.AppImage" -o -name "*.rpm" \ -o -name "*.dmg" -o -name "*.app" -o -name "*.msi" -o -name "*.exe" \ - -o -name "*.apk" \) -exec sh -c ' + -o -name "*.apk" -o -name "*.ipa" \) -exec sh -c ' for f; do size=$(du -h "$f" | cut -f1) echo " ${size} $(basename "$f")" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index febc5b9322..9fd9c1f284 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -48,6 +48,9 @@ "appimage": { "bundleMediaFramework": false } + }, + "iOS": { + "developmentTeam": "" } } } diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index caad789e5f..be51d73c75 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -16,6 +16,7 @@ import { useRoomMembers } from '../../hooks/useRoomMembers'; import { CallView } from '../call/CallView'; import { RoomViewHeader } from './RoomViewHeader'; import { useCallState } from '../../pages/client/call/CallProvider'; +import { WidgetsDrawer } from '../widgets/WidgetsDrawer'; export function Room() { const { eventId } = useParams(); @@ -23,6 +24,7 @@ export function Room() { const mx = useMatrixClient(); const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const [isWidgetDrawerOpen] = useSetting(settingsAtom, 'isWidgetDrawer'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const screenSize = useScreenSizeContext(); const powerLevels = usePowerLevels(room); @@ -60,6 +62,12 @@ export function Room() { )} + {screenSize === ScreenSize.Desktop && isWidgetDrawerOpen && ( + <> + + + + )} ); diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index a8dc00e994..9adad0b129 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -69,6 +69,7 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { useCallState } from '../../pages/client/call/CallProvider'; import { ContainerColor } from '../../styles/ContainerColor.css'; +import { useRoomWidgets } from '../../hooks/useRoomWidgets'; type RoomMenuProps = { room: Room; @@ -277,6 +278,8 @@ export function RoomViewHeader() { : undefined; const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const [widgetDrawer, setWidgetDrawer] = useSetting(settingsAtom, 'isWidgetDrawer'); + const widgets = useRoomWidgets(room); const handleSearchClick = () => { const searchParams: _SearchPathSearchParams = { @@ -450,6 +453,46 @@ export function RoomViewHeader() { )} + {screenSize === ScreenSize.Desktop && ( + + {widgetDrawer ? 'Hide Widgets' : 'Show Widgets'} + + } + > + {(triggerRef) => ( + setWidgetDrawer((d) => !d)} + style={{ position: 'relative' }} + > + {widgets.length > 0 && ( + + + {widgets.length} + + + )} + + + )} + + )} + {screenSize === ScreenSize.Desktop && ( +) => Promise>; + +// Unlike SmallWidgetDriver which auto-grants all capabilities for Element Call, +// this driver provides a capability approval mechanism for untrusted widgets. +export class GenericWidgetDriver extends WidgetDriver { + private readonly mxClient: MatrixClient; + private readonly approveCapabilities: CapabilityApprovalCallback; + + public constructor( + mx: MatrixClient, + private forWidget: Widget, + private forWidgetKind: WidgetKind, + private inRoomId?: string, + approveCapabilities?: CapabilityApprovalCallback + ) { + super(); + this.mxClient = mx; + this.approveCapabilities = approveCapabilities ?? (async (caps) => caps); + } + + public async validateCapabilities(requested: Set): Promise> { + return this.approveCapabilities(requested); + } + + public async sendEvent( + eventType: K, + content: StateEvents[K], + stateKey: string | null, + targetRoomId: string | null + ): Promise; + + public async sendEvent( + eventType: K, + content: TimelineEvents[K], + stateKey: null, + targetRoomId: string | null + ): Promise; + + public async sendEvent( + eventType: string, + content: IContent, + stateKey: string | null = null, + targetRoomId: string | null = null + ): Promise { + const client = this.mxClient; + const roomId = targetRoomId || this.inRoomId; + if (!client || !roomId) throw new Error('Not in a room or not attached to a client'); + + let r: { event_id: string } | null; + if (stateKey !== null) { + r = await client.sendStateEvent( + roomId, + eventType as keyof StateEvents, + content as StateEvents[keyof StateEvents], + stateKey + ); + } else if (eventType === EventType.RoomRedaction) { + r = await client.redactEvent(roomId, content.redacts); + } else { + r = await client.sendEvent( + roomId, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents] + ); + } + return { roomId, eventId: r!.event_id }; + } + + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: K, + content: StateEvents[K], + stateKey: string | null, + targetRoomId: string | null + ): Promise; + + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: K, + content: TimelineEvents[K], + stateKey: null, + targetRoomId: string | null + ): Promise; + + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: string, + content: IContent, + stateKey: string | null = null, + targetRoomId: string | null = null + ): Promise { + const client = this.mxClient; + const roomId = targetRoomId || this.inRoomId; + if (!client || !roomId) throw new Error('Not in a room or not attached to a client'); + + let delayOpts; + if (delay !== null) { + delayOpts = { delay, ...(parentDelayId !== null && { parent_delay_id: parentDelayId }) }; + } else if (parentDelayId !== null) { + delayOpts = { parent_delay_id: parentDelayId }; + } else { + throw new Error('Must provide at least one of delay or parentDelayId'); + } + + let r: SendDelayedEventResponse | null; + if (stateKey !== null) { + r = await client._unstable_sendDelayedStateEvent( + roomId, delayOpts, + eventType as keyof StateEvents, + content as StateEvents[keyof StateEvents], + stateKey + ); + } else { + r = await client._unstable_sendDelayedEvent( + roomId, delayOpts, null, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents] + ); + } + return { roomId, delayId: r.delay_id }; + } + + public async updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise { + await this.mxClient._unstable_updateDelayedEvent(delayId, action); + } + + public async sendToDevice( + eventType: string, + encrypted: boolean, + contentMap: { [userId: string]: { [deviceId: string]: object } } + ): Promise { + const client = this.mxClient; + if (encrypted) { + const crypto = client.getCrypto(); + if (!crypto) throw new Error('E2EE not enabled'); + const invertedContentMap: { [content: string]: { userId: string; deviceId: string }[] } = {}; + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + const key = JSON.stringify(contentMap[userId][deviceId]); + invertedContentMap[key] = invertedContentMap[key] || []; + invertedContentMap[key].push({ userId, deviceId }); + } + } + await Promise.all( + Object.entries(invertedContentMap).map(async ([str, recipients]) => { + const batch = await crypto.encryptToDeviceMessages(eventType, recipients, JSON.parse(str)); + await client.queueToDevice(batch); + }) + ); + } else { + await client.queueToDevice({ + eventType, + batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) => + Object.entries(userContentMap).map(([deviceId, content]) => ({ + userId, deviceId, payload: content, + })) + ), + }); + } + } + + public async readRoomTimeline( + roomId: string, eventType: string, msgtype: string | undefined, + stateKey: string | undefined, limit: number, since: string | undefined + ): Promise { + limit = limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; + const room = this.mxClient.getRoom(roomId); + if (!room) return []; + const results: MatrixEvent[] = []; + const events = room.getLiveTimeline().getEvents(); + for (let i = events.length - 1; i >= 0; i--) { + const ev = events[i]; + if (results.length >= limit) break; + if (since !== undefined && ev.getId() === since) break; + if (ev.getType() !== eventType || ev.isState()) continue; + if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent().msgtype) continue; + if (ev.getStateKey() !== undefined && stateKey !== undefined && ev.getStateKey() !== stateKey) continue; + results.push(ev); + } + return results.map((e) => e.getEffectiveEvent() as IRoomEvent); + } + + public async askOpenID(observer: SimpleObservable): Promise { + return observer.update({ + state: OpenIDRequestState.Allowed, + token: await this.mxClient.getOpenIdToken(), + }); + } + + public async readRoomState( + roomId: string, eventType: string, stateKey: string | undefined + ): Promise { + const room = this.mxClient.getRoom(roomId); + if (!room) return []; + const state = room.getLiveTimeline().getState(Direction.Forward); + if (!state) return []; + if (stateKey === undefined) + return state.getStateEvents(eventType).map((e: MatrixEvent) => e.getEffectiveEvent() as IRoomEvent); + const event = state.getStateEvents(eventType, stateKey); + return event === null ? [] : [event.getEffectiveEvent() as IRoomEvent]; + } + + public async readEventRelations( + eventId: string, roomId?: string, relationType?: string, eventType?: string, + from?: string, to?: string, limit?: number, direction?: 'f' | 'b' + ): Promise { + roomId = roomId ?? this.inRoomId ?? undefined; + if (typeof roomId !== 'string') throw new Error('Error while reading the current room'); + const { events, nextBatch, prevBatch } = await this.mxClient.relations( + roomId, eventId, relationType ?? null, eventType ?? null, + { from, to, limit, dir: direction as Direction } + ); + return { + chunk: events.map((e: MatrixEvent) => e.getEffectiveEvent() as IRoomEvent), + nextBatch: nextBatch ?? undefined, + prevBatch: prevBatch ?? undefined, + }; + } + + public async searchUserDirectory(searchTerm: string, limit?: number): Promise { + const { limited, results } = await this.mxClient.searchUserDirectory({ term: searchTerm, limit }); + return { + limited, + results: results.map((r: any) => ({ + userId: r.user_id, displayName: r.display_name, avatarUrl: r.avatar_url, + })), + }; + } + + public async getMediaConfig(): Promise { + return await this.mxClient.getMediaConfig(); + } + + public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> { + const uploadResult = await this.mxClient.uploadContent(file); + return { contentUri: uploadResult.content_uri }; + } + + public getKnownRooms(): string[] { + return this.mxClient.getVisibleRooms().map((r: Room) => r.roomId); + } + + public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined { + return error instanceof MatrixError + ? { matrix_api_error: (error as any).asWidgetApiErrorData() } + : undefined; + } +} + diff --git a/src/app/features/widgets/IntegrationManager.css.ts b/src/app/features/widgets/IntegrationManager.css.ts new file mode 100644 index 0000000000..928bd6133e --- /dev/null +++ b/src/app/features/widgets/IntegrationManager.css.ts @@ -0,0 +1,28 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const IntegrationManagerOverlay = style({ + width: '80vw', + height: '80vh', + maxWidth: toRem(960), + maxHeight: toRem(720), + backgroundColor: color.Background.Container, + borderRadius: config.radii.R400, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', +}); + +export const IntegrationManagerHeader = style({ + flexShrink: 0, + padding: `0 ${config.space.S200} 0 ${config.space.S300}`, + borderBottomWidth: config.borderWidth.B300, +}); + +export const IntegrationManagerIframe = style({ + flexGrow: 1, + width: '100%', + border: 'none', + display: 'block', +}); + diff --git a/src/app/features/widgets/IntegrationManager.tsx b/src/app/features/widgets/IntegrationManager.tsx new file mode 100644 index 0000000000..f805bd55eb --- /dev/null +++ b/src/app/features/widgets/IntegrationManager.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Box, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Spinner, + Text, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { Room } from 'matrix-js-sdk'; + +import * as css from './IntegrationManager.css'; +import { + useIntegrationManager, + buildIntegrationManagerUrl, +} from '../../hooks/useIntegrationManager'; + +interface IntegrationManagerProps { + room: Room; + open: boolean; + onClose: () => void; +} + +export function IntegrationManager({ room, open, onClose }: IntegrationManagerProps) { + const { managers, scalarToken, loading, error } = useIntegrationManager(); + const iframeRef = useRef(null); + const [iframeLoaded, setIframeLoaded] = useState(false); + + const manager = managers[0]; + + const iframeSrc = manager + ? buildIntegrationManagerUrl(manager.uiUrl, scalarToken, room.roomId) + : null; + + useEffect(() => { + if (!open) return undefined; + + const handleMessage = (event: MessageEvent) => { + if (!manager) return; + + try { + const managerOrigin = new URL(manager.uiUrl).origin; + if (event.origin !== managerOrigin) return; + } catch { + return; + } + + if (event.data?.action === 'close_scalar' || event.data?.action === 'close') { + onClose(); + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [open, manager, onClose]); + + useEffect(() => { + if (!open) setIframeLoaded(false); + }, [open]); + + return ( + }> + + + +
+ + + + Integration Manager + + + + + + + + +
+ + + {loading && ( + + + + Connecting to integration manager... + + + )} + + {error && ( + + + Failed to connect: {error} + + + )} + + {!loading && !error && !manager && ( + + + No integration manager available for this homeserver. + + + )} + + {!loading && !error && iframeSrc && ( + <> + {!iframeLoaded && ( + + + + Loading... + + + )} +