Skip to content

Commit 39ea21a

Browse files
committed
customchannels: add a way to open iframe from the server
1 parent 931eb96 commit 39ea21a

6 files changed

Lines changed: 282 additions & 0 deletions

File tree

src/core/iframeChannels.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { proxy } from 'valtio'
2+
3+
export const iframeState = proxy({
4+
id: '',
5+
url: '',
6+
title: '',
7+
metadata: null as Record<string, any> | null,
8+
})
9+
globalThis.iframeState = iframeState
10+
11+
export const registerIframeChannels = () => {
12+
registerIframeOpenChannel()
13+
}
14+
15+
const registerIframeOpenChannel = () => {
16+
const CHANNEL_NAME = 'minecraft-web-client:iframe-open'
17+
18+
const packetStructure = [
19+
'container',
20+
[
21+
{
22+
name: 'id',
23+
type: ['pstring', { countType: 'i16' }]
24+
},
25+
{
26+
name: 'url',
27+
type: ['pstring', { countType: 'i16' }]
28+
},
29+
{
30+
name: 'title',
31+
type: ['pstring', { countType: 'i16' }]
32+
},
33+
{
34+
name: 'metadataJson',
35+
type: ['pstring', { countType: 'i16' }]
36+
}
37+
]
38+
]
39+
40+
bot._client.registerChannel(CHANNEL_NAME, packetStructure, true)
41+
42+
bot._client.on(CHANNEL_NAME as any, (data) => {
43+
const { id, url, title, metadataJson } = data
44+
45+
let metadata: Record<string, any> | null = null
46+
if (metadataJson && metadataJson.trim() !== '') {
47+
try {
48+
metadata = JSON.parse(metadataJson)
49+
} catch (error) {
50+
console.warn('Failed to parse iframe metadataJson:', error)
51+
}
52+
}
53+
54+
iframeState.id = id
55+
iframeState.url = url
56+
iframeState.title = title || ''
57+
iframeState.metadata = metadata
58+
})
59+
60+
console.debug(`registered custom channel ${CHANNEL_NAME} channel`)
61+
}

src/customChannels.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods'
44
import { options, serverChangedSettings } from './optionsStorage'
55
import { jeiCustomCategories } from './inventoryWindows'
66
import { registerIdeChannels } from './core/ideChannels'
7+
import { registerIframeChannels } from './core/iframeChannels'
78
import { serverSafeSettings } from './defaultOptions'
89
import { lastConnectOptions } from './appStatus'
910
import { gameAdditionalState } from './globalState'
@@ -31,6 +32,7 @@ export default () => {
3132
registerWaypointChannels()
3233
registerFireworksChannels()
3334
registerIdeChannels()
35+
registerIframeChannels()
3436
registerServerSettingsChannel()
3537
registerTypingIndicatorChannel()
3638
})

src/react/IframeModal.css

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
.iframe-modal-container {
2+
position: fixed;
3+
inset: 0;
4+
z-index: 1000;
5+
display: flex;
6+
flex-direction: column;
7+
justify-content: center;
8+
align-items: center;
9+
padding: 16px;
10+
background-color: rgba(0, 0, 0, 0.5);
11+
}
12+
13+
.iframe-modal-title {
14+
font-size: 20px;
15+
font-weight: bold;
16+
color: #fff;
17+
margin-bottom: 8px;
18+
}
19+
20+
.iframe-modal-wrapper {
21+
position: relative;
22+
width: 100%;
23+
height: 100%;
24+
max-width: 80vw;
25+
max-height: 80vh;
26+
border: 3px solid #000;
27+
background-color: #000;
28+
padding: 3px;
29+
box-shadow: inset 0 0 0 1px #fff, inset 0 0 0 2px #000;
30+
}
31+
32+
.iframe-modal-close {
33+
position: fixed;
34+
top: 16px;
35+
left: 16px;
36+
z-index: 1001;
37+
cursor: pointer;
38+
padding: 8px;
39+
}
40+
41+
.iframe-modal-iframe {
42+
width: 100%;
43+
height: 100%;
44+
border: none;
45+
background-color: #fff;
46+
}
47+
48+
.iframe-consent-screen {
49+
width: 100%;
50+
height: 100%;
51+
display: flex;
52+
justify-content: center;
53+
align-items: center;
54+
background-color: #1a1a1a;
55+
}
56+
57+
.iframe-consent-content {
58+
display: flex;
59+
flex-direction: column;
60+
align-items: center;
61+
gap: 24px;
62+
padding: 32px;
63+
text-align: center;
64+
}
65+
66+
.iframe-consent-message {
67+
font-size: 17px;
68+
color: #fff;
69+
line-height: 1.5;
70+
}
71+
72+
.iframe-consent-message strong {
73+
color: #4a9eff;
74+
font-weight: bold;
75+
}
76+
77+
.iframe-consent-button {
78+
min-width: 200px;
79+
}
80+
81+
@media (max-width: 768px) {
82+
.iframe-modal-container {
83+
padding: 0;
84+
}
85+
.iframe-modal-wrapper {
86+
max-width: 100%;
87+
max-height: 100%;
88+
border-radius: 0;
89+
}
90+
.iframe-modal-close {
91+
top: 8px;
92+
left: 8px;
93+
}
94+
.iframe-modal-title {
95+
/* todo: make it work on mobile */
96+
display: none;
97+
}
98+
.iframe-consent-content {
99+
padding: 16px;
100+
}
101+
.iframe-consent-message {
102+
font-size: 16px;
103+
}
104+
}

src/react/IframeModal.tsx

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { proxy, useSnapshot } from 'valtio'
2+
import { useEffect, useMemo, useState } from 'react'
3+
import PixelartIcon, { pixelartIcons } from '../react/PixelartIcon'
4+
import { useIsModalActive } from '../react/utilsApp'
5+
import { hideModal, showModal } from '../globalState'
6+
import { iframeState } from '../core/iframeChannels'
7+
import { lastConnectOptions } from '../appStatus'
8+
import { useAppScale } from '../scaleInterface'
9+
import { appStorage } from './appStorageProvider'
10+
import Button from './Button'
11+
import './IframeModal.css'
12+
13+
const getDomainFromUrl = (url: string): string => {
14+
try {
15+
const urlObj = new URL(url)
16+
return urlObj.hostname
17+
} catch {
18+
// If URL parsing fails, try to extract domain manually
19+
const match = /^(?:https?:\/\/)?([^/]+)/.exec(url)
20+
return match ? match[1] : url
21+
}
22+
}
23+
24+
const getConsentKey = (serverIp: string, domain: string): string => {
25+
return `${serverIp}:${domain}`
26+
}
27+
28+
const checkConsent = (serverIp: string, domain: string): boolean => {
29+
const consentKey = getConsentKey(serverIp, domain)
30+
return appStorage.iframeConsents?.includes(consentKey) ?? false
31+
}
32+
33+
const addConsent = (serverIp: string, domain: string) => {
34+
const consentKey = getConsentKey(serverIp, domain)
35+
if (!appStorage.iframeConsents) {
36+
appStorage.iframeConsents = []
37+
}
38+
if (!appStorage.iframeConsents.includes(consentKey)) {
39+
appStorage.iframeConsents.push(consentKey)
40+
}
41+
}
42+
43+
export default () => {
44+
const { url, title, id, metadata } = useSnapshot(iframeState)
45+
const isModalActive = useIsModalActive('iframe-modal')
46+
const serverIp = lastConnectOptions.value?.server ?? ''
47+
const domain = useMemo(() => (url ? getDomainFromUrl(url) : ''), [url])
48+
const [showConsentScreen, setShowConsentScreen] = useState(true)
49+
const scale = useAppScale()
50+
51+
useEffect(() => {
52+
if (id && url && !isModalActive) {
53+
showModal({ reactType: 'iframe-modal' })
54+
const consent = checkConsent(serverIp, domain)
55+
setShowConsentScreen(!consent)
56+
}
57+
if (!id && isModalActive) {
58+
hideModal()
59+
}
60+
}, [id, url])
61+
62+
63+
const handleConsent = () => {
64+
if (!domain) return
65+
addConsent(serverIp ?? '', domain)
66+
setShowConsentScreen(false)
67+
}
68+
69+
if (!isModalActive) return null
70+
71+
return <div className="iframe-modal-container">
72+
<div className="iframe-modal-close">
73+
<PixelartIcon
74+
iconName={pixelartIcons.close}
75+
width={26}
76+
onClick={() => {
77+
hideModal()
78+
}}
79+
/>
80+
</div>
81+
{title && (
82+
<div className="iframe-modal-title">
83+
{title}
84+
</div>
85+
)}
86+
<div className="iframe-modal-wrapper">
87+
{showConsentScreen ? (
88+
<div className="iframe-consent-screen">
89+
<div className="iframe-consent-content" style={{ transform: `scale(${scale})` }}>
90+
<div className="iframe-consent-message">
91+
Allow <strong>{serverIp}</strong> to open for you <strong>{domain}</strong>?
92+
</div>
93+
<Button
94+
label="Open Page"
95+
onClick={handleConsent}
96+
className="iframe-consent-button"
97+
/>
98+
</div>
99+
</div>
100+
) : (
101+
<iframe
102+
src={url}
103+
className="iframe-modal-iframe"
104+
allow="*"
105+
allowFullScreen
106+
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads allow-modals allow-orientation-lock allow-pointer-lock allow-top-navigation-by-user-activation allow-storage-access-by-user-activation"
107+
/>
108+
)}
109+
</div>
110+
</div>
111+
}

src/react/appStorageProvider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ type StorageData = {
5353
serversList: StoreServerItem[] | undefined
5454
modsAutoUpdateLastCheck: number | undefined
5555
firstModsPageVisit: boolean
56+
iframeConsents: string[] | undefined
5657
}
5758

5859
const cookieStoreKeys: Array<keyof StorageData> = [
@@ -215,6 +216,7 @@ const defaultStorageData: StorageData = {
215216
serversList: undefined,
216217
modsAutoUpdateLastCheck: undefined,
217218
firstModsPageVisit: true,
219+
iframeConsents: undefined,
218220
}
219221

220222
export const setStorageDataOnAppConfigLoad = (appConfig: AppConfig) => {

src/reactUi.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import FullscreenTime from './react/FullscreenTime'
6868
import StorageConflictModal from './react/StorageConflictModal'
6969
import FireRenderer from './react/FireRenderer'
7070
import MonacoEditor from './react/MonacoEditor'
71+
import IframeModal from './react/IframeModal'
7172
import OverlayModelViewer from './react/OverlayModelViewer'
7273
import CornerIndicatorStats from './react/CornerIndicatorStats'
7374
import AllSettingsEditor from './react/AllSettingsEditor'
@@ -270,6 +271,7 @@ const AppBase = () => {
270271
<DebugEdges />
271272
<OverlayModelViewer />
272273
<MonacoEditor />
274+
<IframeModal />
273275
<DebugResponseTimeIndicator />
274276
<CornerIndicatorStats />
275277
</RobustPortal>

0 commit comments

Comments
 (0)