refactor: initial move to a multi-origin architecture

This commit is contained in:
alina 🌸 2025-01-14 06:34:42 +03:00
parent f21a53f088
commit 85ef557149
97 changed files with 1426 additions and 1310 deletions

View file

@ -1,6 +1,15 @@
import antfu from '@antfu/eslint-config'
import tailwind from 'eslint-plugin-tailwindcss'
import tailwindConfig from 'eslint-plugin-tailwindcss/lib/config/rules.js'
const mappedTailwindConfig = {}
for (const [key, value] of Object.entries(tailwindConfig)) {
mappedTailwindConfig[key.replace('tailwindcss/', 'tw/')] = [value, {
config: 'packages/repl/tailwind.config.js',
}]
}
export default antfu({
ignores: [
'src/components/Editor/utils/*.json',
@ -10,6 +19,7 @@ export default antfu({
solid: true,
yaml: false,
rules: {
'node/prefer-global/process': 'off',
'style/multiline-ternary': 'off',
'curly': ['error', 'multi-line'],
'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
@ -21,8 +31,9 @@ export default antfu({
'ts/no-redeclare': 'off',
'unused-imports/no-unused-imports': 'error',
'ts/no-empty-object-type': 'off',
...mappedTailwindConfig,
},
plugins: {
tw: tailwind,
},
}, tailwind.configs['flat/recommended'])
})

View file

@ -1,68 +1,27 @@
{
"name": "mtcute-repl",
"name": "mtcute-repl-workspace",
"type": "module",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@9.5.0",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@badrap/valita": "^0.4.2",
"@corvu/otp-field": "^0.1.4",
"@corvu/resizable": "^0.2.3",
"@fuman/fetch": "^0.0.8",
"@fuman/io": "0.0.8",
"@fuman/utils": "0.0.4",
"@kobalte/core": "^0.13.7",
"@mtcute/convert": "^0.19.4",
"@mtcute/web": "^0.19.5",
"@nanostores/persistent": "^0.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"esbuild": "^0.24.2",
"fflate": "^0.8.2",
"filesize": "^10.1.6",
"idb": "^8.0.1",
"lucide-solid": "^0.445.0",
"memfs": "^4.17.0",
"monaco-editor": "0.52.0",
"monaco-editor-core": "0.52.0",
"monaco-editor-textmate": "^4.0.0",
"monaco-textmate": "^3.0.1",
"nanoid": "^5.0.9",
"nanostores": "^0.11.3",
"onigasm": "^2.2.5",
"semver": "^7.6.3",
"solid-icons": "^1.1.0",
"solid-js": "^1.9.4",
"solid-transition-group": "^0.2.3",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"ts-blank-space": "^0.4.4",
"uqr": "^0.1.2"
"dev": "pnpm -r --parallel dev"
},
"devDependencies": {
"@antfu/eslint-config": "^3.13.0",
"@catppuccin/vscode": "^3.16.0",
"@fuman/fetch": "^0.0.8",
"@types/node": "^22.10.5",
"@types/semver": "^7.5.8",
"autoprefixer": "^10.4.20",
"esbuild": "^0.24.2",
"eslint-plugin-solid": "^0.14.5",
"eslint-plugin-tailwindcss": "^3.17.5",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
"plist2": "^1.1.4",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^5.4.11",
"vite-plugin-solid": "^2.11.0"
},
"pnpm": {
"patchedDependencies": {
"vite-plugin-externalize-dependencies@1.0.1": "patches/vite-plugin-externalize-dependencies@1.0.1.patch"
}
}
}

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@mtcute/repl</title>
<title>@mtcute/playground</title>
</head>
<body>
<div id="root"></div>

View file

@ -0,0 +1,41 @@
{
"name": "mtcute-repl",
"type": "module",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@9.5.0",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@corvu/otp-field": "^0.1.4",
"@corvu/resizable": "^0.2.3",
"@fuman/utils": "0.0.4",
"@kobalte/core": "^0.13.7",
"@nanostores/persistent": "^0.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"filesize": "^10.1.6",
"lucide-solid": "^0.445.0",
"monaco-editor": "0.52.0",
"monaco-editor-core": "0.52.0",
"monaco-editor-textmate": "^4.0.0",
"monaco-textmate": "^3.0.1",
"mtcute-repl-worker": "workspace:*",
"nanoid": "^5.0.9",
"nanostores": "^0.11.3",
"onigasm": "^2.2.5",
"solid-icons": "^1.1.0",
"solid-js": "^1.9.4",
"solid-transition-group": "^0.2.3",
"ts-blank-space": "^0.4.4"
},
"devDependencies": {
"postcss": "^8.4.49",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
}
}

View file

@ -1,22 +1,33 @@
import Resizable from '@corvu/resizable'
import { createSignal, lazy } from 'solid-js'
import { workerInit } from 'mtcute-repl-worker/client'
import { createSignal, lazy, onMount, Show } from 'solid-js'
import { EditorTabs } from './components/editor/EditorTabs.tsx'
import { NavbarMenu } from './components/nav/NavbarMenu.tsx'
import { Runner } from './components/runner/Runner.tsx'
import { SettingsDialog, type SettingsTab } from './components/settings/Settings.tsx'
import { Updater } from './components/Updater.tsx'
import { ResizableHandle, ResizablePanel } from './lib/components/ui/resizable.tsx'
import { Resizable, ResizableHandle, ResizablePanel } from './lib/components/ui/resizable.tsx'
const Editor = lazy(() => import('./components/editor/Editor.tsx'))
export function App() {
const [versions, setVersions] = createSignal<Record<string, string> | undefined>(undefined)
const [updating, setUpdating] = createSignal(true)
const [showSettings, setShowSettings] = createSignal(false)
const [settingsTab, setSettingsTab] = createSignal<SettingsTab>('accounts')
let workerIframe!: HTMLIFrameElement
onMount(() => {
workerInit(workerIframe)
})
return (
<div class="flex h-screen w-screen flex-col overflow-hidden">
<iframe
ref={workerIframe}
class="invisible size-0"
src={import.meta.env.VITE_IFRAME_URL}
/>
<nav class="relative flex h-auto w-full shrink-0 flex-row items-center overflow-hidden px-4 py-2">
<h1 class="font-mono text-base">
@mtcute/
@ -34,11 +45,14 @@ export function App() {
/>
</nav>
<div class="h-px shrink-0 bg-border" />
{versions() === undefined ? (
<Show
when={!updating()}
fallback={(
<Updater
onComplete={setVersions}
onComplete={() => setUpdating(false)}
/>
) : (
)}
>
<Resizable orientation="horizontal" class="size-full max-h-[calc(100vh-57px)]">
<ResizablePanel class="h-full overflow-x-auto overflow-y-hidden" minSize={0.2}>
<EditorTabs />
@ -52,7 +66,7 @@ export function App() {
<Runner />
</ResizablePanel>
</Resizable>
)}
</Show>
<SettingsDialog
show={showSettings()}

View file

@ -0,0 +1,31 @@
import { type TelegramAccount, workerInvoke } from 'mtcute-repl-worker/client'
import { createSignal, onCleanup, onMount } from 'solid-js'
import { Avatar, AvatarFallback, AvatarImage, makeAvatarFallbackText } from '../lib/components/ui/avatar.tsx'
export function AccountAvatar(props: {
class?: string
account: TelegramAccount
}) {
const [url, setUrl] = createSignal<string | undefined>()
onMount(async () => {
try {
const buf = await workerInvoke('telegram', 'fetchAvatar', props.account.id)
if (!buf) return
const url = URL.createObjectURL(new Blob([buf], { type: 'image/jpeg' }))
setUrl(url)
} catch (e) {
console.error(e)
}
})
onCleanup(() => url() && URL.revokeObjectURL(url()!))
return (
<Avatar class={props.class}>
<AvatarImage src={url()} />
<AvatarFallback class="whitespace-nowrap rounded-none">
{makeAvatarFallbackText(props.account.name)}
</AvatarFallback>
</Avatar>
)
}

View file

@ -0,0 +1,58 @@
import { filesize } from 'filesize'
import { workerInvoke, workerOn } from 'mtcute-repl-worker/client'
import { createSignal, onMount } from 'solid-js'
import { Spinner } from '../lib/components/ui/spinner.tsx'
export interface UpdaterProps {
onComplete: () => void
}
export function Updater(props: UpdaterProps) {
const [downloadedBytes, setDownloadedBytes] = createSignal(0)
const [totalBytes, setTotalBytes] = createSignal(Infinity)
const [step, setStep] = createSignal('Idle')
async function runUpdater() {
setStep('Checking for updates...')
const updates = await workerInvoke('vfs', 'checkForUpdates')
if (Object.keys(updates).length === 0) {
props.onComplete()
return
}
setStep('Downloading...')
const cleanup = workerOn('UpdateProgress', ({ progress, total }) => {
setDownloadedBytes(progress)
setTotalBytes(total)
})
await workerInvoke('vfs', 'downloadPackages', updates)
cleanup()
props.onComplete()
}
onMount(() => {
runUpdater()
})
return (
<div class="flex flex-col items-center gap-2 p-4">
<Spinner
class="size-10"
indeterminate
/>
<div class="text-center text-xs text-muted-foreground">
{step()}
{totalBytes() !== Infinity && (
<div>
{filesize(downloadedBytes())}
{' / '}
{filesize(totalBytes())}
</div>
)}
</div>
</div>
)
}

View file

@ -1,7 +1,6 @@
import { editor as mEditor, Uri } from 'monaco-editor'
import { createEffect, on, onMount } from 'solid-js'
import { useColorScheme } from '../../lib/use-color-scheme'
import { VfsStorage } from '../../lib/vfs/storage.ts'
import { $activeTab, $tabs, type EditorTab } from '../../store/tabs.ts'
import { useStore } from '../../store/use-store.ts'
@ -44,8 +43,6 @@ export default function Editor(props: EditorProps) {
const modelsByTab = new Map<string, mEditor.ITextModel>()
onMount(async () => {
const vfs = await VfsStorage.create()
editor = mEditor.create(ref, {
model: null,
automaticLayout: true,
@ -76,7 +73,7 @@ export default function Editor(props: EditorProps) {
scrollBeyondLastLine: false,
})
await setupMonaco(vfs)
await setupMonaco()
for (const tab of tabs()) {
const model = mEditor.createModel(tab.main ? DEFAULT_CODE : '', 'typescript', Uri.parse(`file:///${tab.id}.ts`))

View file

@ -1,11 +1,11 @@
import type { VfsStorage } from '../../../lib/vfs/storage.ts'
import { asNonNull, asyncPool, utf8 } from '@fuman/utils'
import { wireTmGrammars } from 'monaco-editor-textmate'
import { editor, languages } from 'monaco-editor/esm/vs/editor/editor.api.js'
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import { wireTmGrammars } from 'monaco-editor-textmate'
import { Registry } from 'monaco-textmate'
import { loadWASM } from 'onigasm'
import { workerInvoke } from 'mtcute-repl-worker/client'
import { loadWASM } from 'onigasm'
import onigasmWasm from 'onigasm/lib/onigasm.wasm?url'
import TypeScriptWorker from './custom-worker.ts?worker'
import latte from './latte.json'
@ -57,18 +57,19 @@ const compilerOptions: languages.typescript.CompilerOptions = {
languages.typescript.typescriptDefaults.setCompilerOptions(compilerOptions)
languages.typescript.javascriptDefaults.setCompilerOptions(compilerOptions)
export async function setupMonaco(vfs: VfsStorage) {
export async function setupMonaco() {
if (!loadingWasm) loadingWasm = loadWASM(onigasmWasm)
await loadingWasm
const libs = await vfs.getAvailableLibs()
const libs = await workerInvoke('vfs', 'getLibraryNames')
const extraLibs: {
content: string
filePath?: string
}[] = []
await asyncPool(libs, async (lib) => {
const { files } = asNonNull(await vfs.readLibrary(lib))
const { files } = asNonNull(await workerInvoke('vfs', 'getLibrary', lib))
for (const file of files) {
const { path, contents } = file
if (!path.endsWith('.d.ts')) continue

View file

@ -0,0 +1,109 @@
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
import { LucideCheck, LucideChevronDown, LucideExternalLink, LucideLogIn, LucideSettings, LucideUsers } from 'lucide-solid'
import { SiGithub } from 'solid-icons/si'
import { For, Show } from 'solid-js'
import { Button } from '../../lib/components/ui/button.tsx'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../../lib/components/ui/dropdown-menu.tsx'
import { cn } from '../../lib/utils.ts'
import { $accounts, $activeAccount, $activeAccountId } from '../../store/accounts.ts'
import { useStore } from '../../store/use-store.ts'
import { AccountAvatar } from '../AccountAvatar.tsx'
export function NavbarMenu(props: {
onShowAccounts: () => void
onShowSettings: () => void
}) {
const activeAccount = useStore($activeAccount)
const accounts = useStore($accounts)
return (
<Show
when={activeAccount() != null}
fallback={(
<Button
variant="ghost"
size="icon"
class="ml-auto w-auto px-2"
onClick={props.onShowAccounts}
>
<LucideLogIn class="mr-2 size-4 shrink-0" />
Log in
</Button>
)}
>
<DropdownMenu>
<DropdownMenuTrigger
as={(props: DropdownMenuTriggerProps) => (
<Button
class="z-10 ml-auto px-2"
variant="ghost"
{...props}
>
<AccountAvatar class="size-6" account={activeAccount()!} />
<span class="ml-2 max-w-[120px] truncate">
{activeAccount()!.name}
</span>
<LucideChevronDown class="ml-2 size-4" />
</Button>
)}
/>
<DropdownMenuContent class="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuGroupLabel>Accounts</DropdownMenuGroupLabel>
<For each={accounts()}>
{account => (
<DropdownMenuItem onClick={() => $activeAccountId.set(account.id)}>
<AccountAvatar
class={cn(
'size-4',
account.id !== activeAccount()?.id && 'opacity-50',
)}
account={account}
/>
<span
class={cn(
'ml-2',
account.id === activeAccount()?.id ? 'font-semibold' : 'text-muted-foreground',
)}
>
{account.name}
</span>
{account.id === activeAccount()?.id && <LucideCheck class="ml-auto size-4" />}
</DropdownMenuItem>
)}
</For>
<DropdownMenuItem onClick={props.onShowAccounts}>
<LucideUsers class="mr-2 size-4" />
Manage accounts
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={props.onShowSettings}>
<LucideSettings class="mr-2 size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
as="a"
class="cursor-pointer"
href="https://github.com/mtcute/repl"
target="_blank"
>
<SiGithub class="mr-2 size-4" />
GitHub
<LucideExternalLink class="ml-auto size-4" />
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</Show>
)
}

View file

@ -13,13 +13,15 @@ const HTML = `
`
const INJECTED_SCRIPT = `
async function waitForElement(selector, container) {
let tabbedPane;
while(!tabbedPane) {
tabbedPane = container.querySelector(selector);
if (!tabbedPane) await new Promise(resolve => setTimeout(resolve, 50));
async function waitForElement(selector, container, waitForShadowRoot = false) {
let el;
while(!el) {
el = container.querySelector(selector);
if (!el || (waitForShadowRoot && !el.shadowRoot)) {
await new Promise(resolve => setTimeout(resolve, 50));
}
return tabbedPane;
}
return el;
}
function hideBySelector(root, selector) {
@ -28,8 +30,8 @@ function hideBySelector(root, selector) {
el.style.display = 'none';
}
function focusConsole(tabbedPane) {
const consoleTab = tabbedPane.shadowRoot.querySelector('#tab-console');
async function focusConsole(tabbedPane) {
const consoleTab = await waitForElement('#tab-console', tabbedPane.shadowRoot);
// tabs get focused on mousedown instead of click
consoleTab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
@ -38,7 +40,7 @@ function focusConsole(tabbedPane) {
(async ()=> {
const tabbedPane = await waitForElement('.tabbed-pane', document.body);
focusConsole(tabbedPane);
await focusConsole(tabbedPane);
hideBySelector(tabbedPane, '.tabbed-pane-header');
const consoleToolbar = await waitForElement('.console-main-toolbar', document.body);

View file

@ -1,8 +1,10 @@
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
import type { ConnectionState } from '@mtcute/web'
import type { CustomTypeScriptWorker } from '../editor/utils/custom-worker.ts'
import { LucideCheck, LucidePlay, LucidePlug, LucideRefreshCw, LucideUnplug } from 'lucide-solid'
import { timers } from '@fuman/utils'
import { persistentAtom } from '@nanostores/persistent'
import { LucideCheck, LucidePlay, LucidePlug, LucideRefreshCw, LucideSkull, LucideUnplug } from 'lucide-solid'
import { languages, Uri } from 'monaco-editor/esm/vs/editor/editor.api.js'
import { type mtcute, workerInvoke } from 'mtcute-repl-worker/client'
import { nanoid } from 'nanoid'
import { createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
import { Button } from '../../lib/components/ui/button.tsx'
@ -11,22 +13,74 @@ import { cn } from '../../lib/utils.ts'
import { $activeAccountId } from '../../store/accounts.ts'
import { $tabs } from '../../store/tabs.ts'
import { useStore } from '../../store/use-store.ts'
import { swForgetScript, swUploadScript } from '../../sw/client.ts'
import { Devtools } from './Devtools.tsx'
const $disconnectAfterSecs = persistentAtom('repl:disconnectAfterSecs', 60, {
encode: String,
decode: Number,
})
export function Runner() {
const [devtoolsIframe, setDevtoolsIframe] = createSignal<HTMLIFrameElement | undefined>()
const [runnerIframe, setRunnerIframe] = createSignal<HTMLIFrameElement>()
const [runnerLoaded, setRunnerLoaded] = createSignal(false)
const [running, setRunning] = createSignal(false)
const [connectionState, setConnectionState] = createSignal<ConnectionState>('offline')
const [dead, setDead] = createSignal(false)
const [connectionState, setConnectionState] = createSignal<mtcute.ConnectionState>('offline')
const currentAccountId = useStore($activeAccountId)
const disconnectAfterSecs = useStore($disconnectAfterSecs)
let currentScriptId: string | undefined
let deadTimer: timers.Timer | undefined
let inactivityTimer: timers.Timer | undefined
let iframeContainerRef!: HTMLIFrameElement
let runnerIframeRef!: HTMLIFrameElement
function rescheduleInactivityTimer() {
if (inactivityTimer) timers.clearTimeout(inactivityTimer)
if (connectionState() === 'offline') return
if (disconnectAfterSecs() === -1) return
inactivityTimer = timers.setTimeout(() => {
inactivityTimer = undefined
handleDisconnect()
}, disconnectAfterSecs() * 1000)
}
function setInactivityTimeout(secs: number) {
$disconnectAfterSecs.set(secs)
rescheduleInactivityTimer()
}
function recreateIframe() {
runnerIframe()?.remove()
setRunnerIframe(undefined)
setRunnerLoaded(false)
setConnectionState('offline')
setDead(false)
timers.clearTimeout(deadTimer)
const iframe = document.createElement('iframe')
iframe.className = 'invisible size-0'
iframe.src = `${import.meta.env.VITE_IFRAME_URL}/sw/runtime/_iframe.html`
iframe.onload = () => {
iframe.contentWindow!.postMessage({
event: 'INIT',
accountId: currentAccountId(),
}, '*')
setRunnerLoaded(true)
deadTimer = timers.setTimeout(() => {
setDead(true)
}, 2000)
}
iframeContainerRef.appendChild(iframe)
setRunnerIframe(iframe)
}
onMount(recreateIframe)
function handleMessage(e: MessageEvent) {
if (e.source === runnerIframeRef.contentWindow) {
if (e.source === runnerIframe()?.contentWindow) {
// event from runner iframe
switch (e.data.event) {
case 'TO_DEVTOOLS': {
@ -35,12 +89,22 @@ export function Runner() {
}
case 'SCRIPT_END': {
setRunning(false)
swForgetScript(currentScriptId!)
workerInvoke('sw', 'forgetScript', { name: currentScriptId! })
currentScriptId = undefined
rescheduleInactivityTimer()
break
}
case 'CONNECTION_STATE': {
setConnectionState(e.data.value)
if (e.data.value === 'connected') {
rescheduleInactivityTimer()
}
break
}
case 'PING': {
if (deadTimer) {
timers.clearTimeout(deadTimer)
}
break
}
}
@ -62,7 +126,11 @@ export function Runner() {
return
}
runnerIframeRef.contentWindow!.postMessage({ event: 'FROM_DEVTOOLS', value: e.data }, '*')
if (data.method === 'Runtime.evaluate' && data.params.userGesture) {
rescheduleInactivityTimer()
}
runnerIframe()?.contentWindow!.postMessage({ event: 'FROM_DEVTOOLS', value: e.data }, '*')
}
}
@ -75,7 +143,7 @@ export function Runner() {
createEffect(on(currentAccountId, (accountId) => {
if (!runnerLoaded()) return
runnerIframeRef.contentWindow!.postMessage({
runnerIframe()!.contentWindow!.postMessage({
event: 'ACCOUNT_CHANGED',
accountId,
}, '*')
@ -100,44 +168,42 @@ export function Runner() {
}
currentScriptId = nanoid()
await swUploadScript(currentScriptId, files)
await workerInvoke('sw', 'uploadScript', { name: currentScriptId, files })
runnerIframeRef.contentWindow!.postMessage({
runnerIframe()!.contentWindow!.postMessage({
event: 'RUN',
scriptId: currentScriptId,
exports,
}, '*')
setRunning(true)
timers.clearTimeout(inactivityTimer)
}
function handleDisconnect() {
runnerIframeRef.contentWindow!.postMessage({ event: 'DISCONNECT' }, '*')
runnerIframe()?.contentWindow!.postMessage({ event: 'DISCONNECT' }, '*')
}
function handleConnect() {
runnerIframeRef.contentWindow!.postMessage({ event: 'RECONNECT' }, '*')
}
function handleRestart() {
runnerIframeRef.contentWindow!.location.reload()
runnerIframe()?.contentWindow!.postMessage({ event: 'RECONNECT' }, '*')
}
return (
<>
<div class="flex shrink-0 flex-row p-1">
<iframe
class="invisible size-0"
src="/sw/runtime/_iframe.html"
ref={runnerIframeRef}
onLoad={() => {
runnerIframeRef.contentWindow!.postMessage({
event: 'INIT',
accountId: currentAccountId(),
}, '*')
setRunnerLoaded(true)
}}
/>
<div ref={iframeContainerRef} />
<div class="flex w-full grow-0 flex-row">
{dead() ? (
<Button
variant="ghostDestructive"
size="xs"
onClick={recreateIframe}
>
<LucideSkull
class="mr-2 size-3"
/>
Terminate
</Button>
) : (
<Button
variant="ghost"
size="xs"
@ -149,6 +215,7 @@ export function Runner() {
/>
Run
</Button>
)}
<div class="flex-1" />
<DropdownMenu>
<DropdownMenuTrigger
@ -194,7 +261,7 @@ export function Runner() {
{connectionState() === 'offline' ? 'Connect' : 'Disconnect'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleRestart}
onClick={recreateIframe}
class="text-xs"
>
<LucideRefreshCw
@ -207,18 +274,21 @@ export function Runner() {
<DropdownMenuGroupLabel class="text-xs">
Auto-disconnect after
</DropdownMenuGroupLabel>
<DropdownMenuItem class="text-xs">
<DropdownMenuItem class="text-xs" onClick={() => setInactivityTimeout(60)}>
1 minute
<LucideCheck class="ml-auto size-3" />
{disconnectAfterSecs() === 60 && <LucideCheck class="ml-auto size-3" />}
</DropdownMenuItem>
<DropdownMenuItem class="text-xs">
<DropdownMenuItem class="text-xs" onClick={() => setInactivityTimeout(300)}>
5 minutes
{disconnectAfterSecs() === 300 && <LucideCheck class="ml-auto size-3" />}
</DropdownMenuItem>
<DropdownMenuItem class="text-xs">
<DropdownMenuItem class="text-xs" onClick={() => setInactivityTimeout(1500)}>
15 minutes
{disconnectAfterSecs() === 1500 && <LucideCheck class="ml-auto size-3" />}
</DropdownMenuItem>
<DropdownMenuItem class="text-xs">
<DropdownMenuItem class="text-xs" onClick={() => setInactivityTimeout(-1)}>
Never
{disconnectAfterSecs() === -1 && <LucideCheck class="ml-auto size-3" />}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>

View file

@ -1,6 +1,5 @@
import type { BaseTelegramClient, User } from '@mtcute/web'
import type { TelegramAccount } from '../../store/accounts.ts'
import type { LoginStep, StepContext } from '../login/Login.tsx'
import type { TelegramAccount } from 'mtcute-repl-worker/client'
import type { LoginStep } from './login/Login.tsx'
import { timers } from '@fuman/utils'
import {
LucideBot,
@ -12,30 +11,28 @@ import {
LucideUser,
LucideX,
} from 'lucide-solid'
import { workerInvoke } from 'mtcute-repl-worker/client'
import { nanoid } from 'nanoid'
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from 'solid-js'
import { Badge } from '../../lib/components/ui/badge.tsx'
import { Button } from '../../lib/components/ui/button.tsx'
import { Dialog, DialogContent } from '../../lib/components/ui/dialog.tsx'
import { TextField, TextFieldFrame, TextFieldRoot } from '../../lib/components/ui/text-field.tsx'
import { createInternalClient, deleteAccount } from '../../lib/telegram.ts'
import { cn } from '../../lib/utils.ts'
import { $accounts, $activeAccountId } from '../../store/accounts.ts'
import { useStore } from '../../store/use-store.ts'
import { AccountAvatar } from '../AccountAvatar.tsx'
import { LoginForm } from '../login/Login.tsx'
import { ImportDropdown } from './import/ImportDropdown.tsx'
import { LoginForm } from './login/Login.tsx'
function AddAccountDialog(props: {
show: boolean
testMode: boolean
onClose: () => void
onAccountCreated: (accountId: string, user: User, dcId: number) => void
// onAccountCreated: (accountId: string, user: User, dcId: number) => void
}) {
const [client, setClient] = createSignal<BaseTelegramClient | undefined>(undefined)
let accountId: string
const [accountId, setAccountId] = createSignal<string | undefined>(undefined)
let closeTimeout: timers.Timer | undefined
let finished = false
@ -44,39 +41,47 @@ function AddAccountDialog(props: {
finished = false
} else {
props.onClose()
client()?.close()
// client()?.close()
timers.clearTimeout(closeTimeout)
}
}
async function handleStepChange(step: LoginStep, ctx: Partial<StepContext>) {
async function handleStepChange(step: LoginStep) {
if (step === 'done') {
finished = true
const client_ = client()!
const dcs = await client_.mt.storage.dcs.fetch()
props.onAccountCreated?.(accountId, ctx.done!.user, dcs?.main.id ?? 2)
closeTimeout = timers.setTimeout(() => {
props.onClose()
client_.close()
workerInvoke('telegram', 'disposeClient', { accountId: accountId()! })
}, 2500)
}
}
createEffect(on(() => props.show, async (show) => {
if (!show) {
if (!finished && accountId) {
await client()?.close()
await deleteAccount(accountId)
if (!finished && accountId()) {
await workerInvoke('telegram', 'disposeClient', {
accountId: accountId()!,
forget: true,
})
}
}
accountId = nanoid()
setClient(createInternalClient(accountId, props.testMode))
finished = false
setAccountId(nanoid())
await workerInvoke('telegram', 'createClient', {
accountId: accountId()!,
testMode: props.testMode,
})
}))
onCleanup(() => {
timers.clearTimeout(closeTimeout)
client()?.close()
if (accountId()) {
workerInvoke('telegram', 'disposeClient', {
accountId: accountId()!,
forget: !finished,
})
}
})
return (
@ -91,12 +96,12 @@ function AddAccountDialog(props: {
</Badge>
)}
<div class="flex h-[420px] flex-col justify-center">
{client() && (
<Show when={accountId()}>
<LoginForm
client={client()!}
accountId={accountId()!}
onStepChange={handleStepChange}
/>
)}
</Show>
</div>
</DialogContent>
</Dialog>
@ -162,11 +167,11 @@ function AccountRow(props: {
<LucideLogIn class="size-4" />
</Button>
<Button
variant="ghost"
variant="ghostDestructive"
size="icon"
class="size-8 hover:bg-error"
class="size-8"
>
<LucideTrash class="size-4 text-error-foreground" />
<LucideTrash class="size-4" />
</Button>
</div>
</div>
@ -186,21 +191,6 @@ export function AccountsTab() {
setAddAccountTestMode(e.ctrlKey || e.metaKey)
}
function handleAccountCreated(accountId: string, user: User, dcId: number) {
$accounts.set([
...$accounts.get(),
{
id: accountId,
name: user.displayName,
telegramId: user.id,
bot: user.isBot,
testMode: addAccountTestMode(),
dcId,
},
])
$activeAccountId.set(accountId)
}
const filteredAccounts = createMemo(() => {
const query = searchQuery().toLowerCase().trim()
if (query === '') {
@ -219,6 +209,7 @@ export function AccountsTab() {
fallback={(
<div class="flex h-full flex-col items-center justify-center gap-4 text-muted-foreground">
No accounts yet
<div class="flex flex-row gap-2">
<Button
variant="outline"
size="sm"
@ -227,6 +218,8 @@ export function AccountsTab() {
<LucidePlus class="mr-2 size-4" />
Log in
</Button>
<ImportDropdown size="sm" />
</div>
</div>
)}
@ -260,7 +253,7 @@ export function AccountsTab() {
Log in
</Button>
<ImportDropdown />
<ImportDropdown size="xs" />
</div>
<div class="flex max-w-full flex-col gap-1 overflow-hidden">
<For each={filteredAccounts()}>
@ -280,7 +273,6 @@ export function AccountsTab() {
show={showAddAccount()}
testMode={addAccountTestMode()}
onClose={() => setShowAddAccount(false)}
onAccountCreated={handleAccountCreated}
/>
</>
)

View file

@ -1,12 +1,9 @@
import type { InputStringSessionData } from '@mtcute/web/utils.js'
import { hex } from '@fuman/utils'
import { DC_MAPPING_PROD, DC_MAPPING_TEST } from '@mtcute/convert'
import { createEffect, createSignal, on } from 'solid-js'
import { Button } from '../../../lib/components/ui/button.tsx'
import { Checkbox, CheckboxControl, CheckboxLabel } from '../../../lib/components/ui/checkbox.tsx'
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.tsx'
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
import { deleteAccount, importAccount } from '../../../lib/telegram.ts'
import { $accounts } from '../../../store/accounts.ts'
export function AuthKeyImportDialog(props: {

View file

@ -12,10 +12,11 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '../../../lib/components/ui/dropdown-menu.tsx'
import { cn } from '../../../lib/utils.ts'
import { AuthKeyImportDialog } from './AuthKeyImportDialog.tsx'
import { StringSessionDefs, StringSessionImportDialog } from './StringSessionImportDialog.tsx'
export function ImportDropdown() {
export function ImportDropdown(props: { size: 'xs' | 'sm' }) {
const [showImportStringSession, setShowImportStringSession] = createSignal(false)
const [stringSessionLibName, setStringSessionLibName] = createSignal<StringSessionLibName>('mtcute')
const [showImportAuthKey, setShowImportAuthKey] = createSignal(false)
@ -24,13 +25,21 @@ export function ImportDropdown() {
<>
<DropdownMenu>
<DropdownMenuTrigger
as={(props: DropdownMenuTriggerProps) => (
as={(triggerProps: DropdownMenuTriggerProps) => (
<Button
variant="outline"
size="xs"
{...props}
size={props.size}
{...triggerProps}
>
<LucideDownload class="mr-2 size-3" />
<LucideDownload class={
cn(
{
xs: 'mr-2 size-3',
sm: 'mr-2 size-3.5',
}[props.size],
)
}
/>
Import
</Button>
)}

View file

@ -1,11 +1,8 @@
import type { StringSessionData } from '@mtcute/web/utils.js'
import { readStringSession } from '@mtcute/web/utils.js'
import { createEffect, createSignal, on } from 'solid-js'
import { Button } from '../../../lib/components/ui/button.tsx'
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.tsx'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../lib/components/ui/select.tsx'
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
import { deleteAccount, importAccount } from '../../../lib/telegram.ts'
import { $accounts } from '../../../store/accounts.ts'
export type StringSessionLibName =
@ -26,29 +23,29 @@ export const StringSessionDefs: {
{ name: 'mtkruto', displayName: 'MTKruto' },
]
async function convert(libName: StringSessionLibName, session: string): Promise<StringSessionData> {
switch (libName) {
case 'mtcute': {
return readStringSession(session)
}
case 'telethon': {
const { convertFromTelethonSession } = await import('@mtcute/convert')
return convertFromTelethonSession(session)
}
case 'gramjs': {
const { convertFromGramjsSession } = await import('@mtcute/convert')
return convertFromGramjsSession(session)
}
case 'pyrogram': {
const { convertFromPyrogramSession } = await import('@mtcute/convert')
return convertFromPyrogramSession(session)
}
case 'mtkruto': {
const { convertFromMtkrutoSession } = await import('@mtcute/convert')
return convertFromMtkrutoSession(session)
}
}
}
// async function convert(libName: StringSessionLibName, session: string): Promise<StringSessionData> {
// switch (libName) {
// case 'mtcute': {
// return readStringSession(session)
// }
// case 'telethon': {
// const { convertFromTelethonSession } = await import('@mtcute/convert')
// return convertFromTelethonSession(session)
// }
// case 'gramjs': {
// const { convertFromGramjsSession } = await import('@mtcute/convert')
// return convertFromGramjsSession(session)
// }
// case 'pyrogram': {
// const { convertFromPyrogramSession } = await import('@mtcute/convert')
// return convertFromPyrogramSession(session)
// }
// case 'mtkruto': {
// const { convertFromMtkrutoSession } = await import('@mtcute/convert')
// return convertFromMtkrutoSession(session)
// }
// }
// }
export function StringSessionImportDialog(props: {
open: boolean

View file

@ -1,17 +1,15 @@
import type { BaseTelegramClient, SentCode, User } from '@mtcute/web'
import { base64 } from '@fuman/utils'
import { tl } from '@mtcute/web'
import { checkPassword, downloadAsBuffer, resendCode, sendCode, signIn, signInQr } from '@mtcute/web/methods.js'
import type { mtcute, TelegramAccount } from 'mtcute-repl-worker/client'
import { unknownToError } from '@fuman/utils'
import { LucideChevronRight } from 'lucide-solid'
import { workerInvoke, workerOn } from 'mtcute-repl-worker/client'
import { createEffect, createSignal, For, Match, onCleanup, onMount, Show, Switch } from 'solid-js'
import { renderSVG } from 'uqr'
import { Avatar, AvatarFallback, AvatarImage, makeAvatarFallbackText } from '../../lib/components/ui/avatar.tsx'
import { Button } from '../../lib/components/ui/button.tsx'
import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSlot } from '../../lib/components/ui/otp-field.tsx'
import { Spinner } from '../../lib/components/ui/spinner.tsx'
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../lib/components/ui/text-field.tsx'
import { TransitionSlideLtr } from '../../lib/components/ui/transition.tsx'
import { cn } from '../../lib/utils.ts'
import { Button } from '../../../lib/components/ui/button.tsx'
import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSlot } from '../../../lib/components/ui/otp-field.tsx'
import { Spinner } from '../../../lib/components/ui/spinner.tsx'
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
import { TransitionSlideLtr } from '../../../lib/components/ui/transition.tsx'
import { cn } from '../../../lib/utils.ts'
import { AccountAvatar } from '../../AccountAvatar.tsx'
import { PhoneInput } from './PhoneInput.tsx'
export type LoginStep =
@ -25,14 +23,14 @@ export interface StepContext {
phone: void
otp: {
phone: string
code: SentCode
code: mtcute.SentCode
}
password: void
done: { user: User }
done: { account: TelegramAccount }
}
type StepProps<T extends LoginStep> = {
client: BaseTelegramClient
accountId: string
setStep: <T extends LoginStep>(step: T, data?: StepContext[T]) => void
} & (StepContext[T] extends void ? {} : { ctx: StepContext[T] })
@ -41,23 +39,29 @@ function QrLoginStep(props: StepProps<'qr'>) {
const [finalizing, setFinalizing] = createSignal(false)
const abortController = new AbortController()
onMount(() => {
signInQr(props.client, {
abortSignal: abortController.signal,
onUrlUpdated: qr => setQr(renderSVG(qr)),
onQrScanned: () => setFinalizing(true),
}).then((user) => {
props.setStep('done', { user })
}).catch((e) => {
setFinalizing(false)
if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED')) {
props.setStep('password')
} else if (abortController.signal.aborted) {
// ignore
} else {
throw e
}
onMount(async () => {
const cleanup1 = workerOn('QrCodeUpdate', (e) => {
if (e.accountId !== props.accountId) return
setQr(e.qrCode)
})
const cleanup2 = workerOn('QrCodeScanned', (e) => {
if (e.accountId !== props.accountId) return
setFinalizing(true)
})
onCleanup(() => {
cleanup1()
cleanup2()
})
const result = await workerInvoke('telegram', 'signInQr', {
accountId: props.accountId,
abortSignal: abortController.signal,
})
if (result === 'need_password') {
props.setStep('password')
} else {
props.setStep('done', { account: result })
}
})
onCleanup(() => abortController.abort())
@ -104,26 +108,29 @@ function PhoneNumberStep(props: StepProps<'phone'>) {
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
const abortController = new AbortController()
const handleSubmit = () => {
const handleSubmit = async () => {
setError(undefined)
setLoading(true)
sendCode(props.client, {
try {
const code = await workerInvoke('telegram', 'sendCode', {
accountId: props.accountId,
phone: phone(),
abortSignal: abortController.signal,
}).then((code) => {
})
setLoading(false)
props.setStep('otp', {
code,
phone: phone(),
})
}).catch((e) => {
} catch (e) {
setLoading(false)
if (abortController.signal.aborted) {
// ignore
} else {
setError(e.message)
setError(unknownToError(e).message)
}
}
})
}
onCleanup(() => abortController.abort())
createEffect(() => inputRef()?.focus())
@ -150,7 +157,7 @@ function PhoneNumberStep(props: StepProps<'phone'>) {
<div class="flex flex-row">
<PhoneInput
class="w-[300px]"
client={props.client}
accountId={props.accountId}
onChange={setPhone}
onSubmit={handleSubmit}
disabled={loading()}
@ -192,50 +199,57 @@ function OtpStep(props: StepProps<'otp'>) {
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
const abortController = new AbortController()
const handleSubmit = () => {
const handleSubmit = async () => {
setError(undefined)
setLoading(true)
signIn(props.client, {
try {
const account = await workerInvoke('telegram', 'signIn', {
accountId: props.accountId,
phone: props.ctx.phone,
phoneCodeHash: props.ctx.code.phoneCodeHash,
phoneCode: otp(),
abortSignal: abortController.signal,
}).then((user) => {
setLoading(false)
props.setStep('done', { user })
}).catch((e) => {
})
if (account === 'need_password') {
props.setStep('password')
} else {
props.setStep('done', { account })
}
} catch (e) {
setLoading(false)
if (abortController.signal.aborted) {
// ignore
} else if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED')) {
props.setStep('password')
} else {
setError(e.message)
setError(unknownToError(e).message)
}
})
}
const handleResend = () => {
}
const handleResend = async () => {
setError(undefined)
setLoading(true)
resendCode(props.client, {
try {
const code = await workerInvoke('telegram', 'resendCode', {
accountId: props.accountId,
phone: props.ctx.phone,
phoneCodeHash: props.ctx.code.phoneCodeHash,
abortSignal: abortController.signal,
}).then((code) => {
})
setLoading(false)
props.setStep('otp', {
code,
phone: props.ctx.phone,
})
}).catch((e) => {
} catch (e) {
setLoading(false)
if (abortController.signal.aborted) {
// ignore
} else {
setError(e.message)
setError(unknownToError(e).message)
}
})
}
}
const handleSetOtp = (otp: string) => {
setOtp(otp)
if (otp.length === props.ctx.code.length) {
@ -385,8 +399,10 @@ function PasswordStep(props: StepProps<'password'>) {
const [loading, setLoading] = createSignal(false)
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
// todo abort controller
const handleSubmit = () => {
const abortController = new AbortController()
onCleanup(() => abortController.abort())
const handleSubmit = async () => {
if (!password()) {
setError('Password is required')
return
@ -394,19 +410,17 @@ function PasswordStep(props: StepProps<'password'>) {
setError(undefined)
setLoading(true)
checkPassword(props.client, password())
.then((user) => {
setLoading(false)
props.setStep('done', { user })
try {
const user = await workerInvoke('telegram', 'checkPassword', {
accountId: props.accountId,
password: password(),
abortSignal: abortController.signal,
})
.catch((e) => {
props.setStep('done', { account: user })
} catch (e) {
setLoading(false)
if (tl.RpcError.is(e, 'PASSWORD_HASH_INVALID')) {
setError('Incorrect password')
} else {
setError(e.message)
setError(unknownToError(e).message)
}
})
}
createEffect(() => inputRef()?.focus())
@ -456,47 +470,16 @@ function PasswordStep(props: StepProps<'password'>) {
}
function DoneStep(props: StepProps<'done'>) {
const [avatar, setAvatar] = createSignal<string | null>(null)
onMount(() => {
if (!props.ctx.user.photo) {
props.client.close()
return
}
downloadAsBuffer(props.client, props.ctx.user.photo.big)
.then((buf) => {
const url = URL.createObjectURL(new Blob([buf], { type: 'image/jpeg' }))
setAvatar(url)
})
.catch((e) => {
console.error(e)
})
})
onCleanup(() => {
if (avatar()) {
URL.revokeObjectURL(avatar()!)
}
})
return (
<div class="flex flex-col items-center justify-center">
<Avatar class="mb-4 size-24 shadow-sm">
{props.ctx.user.photo && (
<>
{avatar() && <AvatarImage src={avatar()!} />}
<AvatarImage src={`data:image/jpeg;base64,${base64.encode(props.ctx.user.photo.thumb!)}`} />
</>
)}
<AvatarFallback class="text-xl">
{makeAvatarFallbackText(props.ctx.user.displayName)}
</AvatarFallback>
</Avatar>
<AccountAvatar
account={props.ctx.account}
class="mb-4 size-24 shadow-sm"
/>
<div class="text-center font-medium">
Welcome,
{' '}
{props.ctx.user.displayName}
{props.ctx.account.name}
!
</div>
</div>
@ -505,7 +488,7 @@ function DoneStep(props: StepProps<'done'>) {
export function LoginForm(props: {
class?: string
client: BaseTelegramClient
accountId: string
onStepChange?: (step: LoginStep, ctx: Partial<StepContext>) => void
}) {
const [step, setStep] = createSignal<LoginStep>('qr')
@ -522,20 +505,20 @@ export function LoginForm(props: {
<TransitionSlideLtr mode="outin">
<Switch>
<Match when={step() === 'qr'}>
<QrLoginStep client={props.client} setStep={setStepWithCtx} />
<QrLoginStep accountId={props.accountId} setStep={setStepWithCtx} />
</Match>
<Match when={step() === 'phone'}>
<PhoneNumberStep client={props.client} setStep={setStepWithCtx} />
<PhoneNumberStep accountId={props.accountId} setStep={setStepWithCtx} />
</Match>
<Match when={step() === 'otp'}>
<OtpStep client={props.client} setStep={setStepWithCtx} ctx={ctx().otp!} />
<OtpStep accountId={props.accountId} setStep={setStepWithCtx} ctx={ctx().otp!} />
</Match>
<Match when={step() === 'password'}>
<PasswordStep client={props.client} setStep={setStepWithCtx} />
<PasswordStep accountId={props.accountId} setStep={setStepWithCtx} />
</Match>
<Match when={step() === 'done'}>
<DoneStep
client={props.client}
accountId={props.accountId}
setStep={setStepWithCtx}
ctx={ctx().done!}
/>

View file

@ -1,10 +1,8 @@
import type { BaseTelegramClient, tl } from '@mtcute/web'
import { assert } from '@fuman/utils'
import { type mtcute, workerInvoke } from 'mtcute-repl-worker/client'
import { createSignal, onMount, Show } from 'solid-js'
import { CountryIcon } from '../../lib/components/country-icon.tsx'
import { TextField, TextFieldFrame } from '../../lib/components/ui/text-field.tsx'
import { cn } from '../../lib/utils.ts'
import { CountryIcon } from '../../../lib/components/country-icon.tsx'
import { TextField, TextFieldFrame } from '../../../lib/components/ui/text-field.tsx'
import { cn } from '../../../lib/utils.ts'
interface ChosenCode {
patterns?: string[]
@ -12,7 +10,7 @@ interface ChosenCode {
iso2: string
}
function mapCountryCode(country: tl.help.RawCountry, code: tl.help.RawCountryCode): ChosenCode {
function mapCountryCode(country: mtcute.RawCountry, code: mtcute.RawCountryCode): ChosenCode {
return {
patterns: code.patterns,
countryCode: code.countryCode,
@ -25,28 +23,24 @@ interface PhoneInputProps {
phone?: string
onChange?: (phone: string) => void
onSubmit?: () => void
client: BaseTelegramClient
accountId: string
disabled?: boolean
ref?: (el: HTMLInputElement) => void
}
export function PhoneInput(props: PhoneInputProps) {
const [countriesList, setCountriesList] = createSignal<tl.help.RawCountry[]>([])
const [countriesList, setCountriesList] = createSignal<mtcute.RawCountry[]>([])
const [chosenCode, setChosenCode] = createSignal<ChosenCode | undefined>()
const [inputValue, setInputValue] = createSignal('+')
onMount(() => {
Promise.all([
props.client.call({ _: 'help.getCountriesList', langCode: 'en', hash: 0 }),
props.client.call({ _: 'help.getNearestDc' }),
]).then(([countriesList, nearestDc]) => {
assert(countriesList._ === 'help.countriesList') // todo caching
setCountriesList(countriesList.countries)
onMount(async () => {
const { countries, countryByIp } = await workerInvoke('telegram', 'loadCountries', { accountId: props.accountId })
setCountriesList(countries)
if (inputValue() === '+') {
// guess the country code
for (const country of countriesList.countries) {
if (country.iso2 === nearestDc.country.toUpperCase()) {
for (const country of countries) {
if (country.iso2 === countryByIp) {
setChosenCode(mapCountryCode(country, country.countryCodes[0]))
setInputValue(`+${country.countryCodes[0].countryCode} `)
break
@ -54,7 +48,6 @@ export function PhoneInput(props: PhoneInputProps) {
}
}
})
})
const handleInput = (e: InputEvent) => {
const el = e.currentTarget as HTMLInputElement
@ -77,7 +70,7 @@ export function PhoneInput(props: PhoneInputProps) {
el.value = `+${value.replace(/[^\d ]/g, '')}`
// pass 1: find matching countries by country code
const matching: [tl.help.RawCountry, tl.help.RawCountryCode][] = []
const matching: [mtcute.RawCountry, mtcute.RawCountryCode][] = []
let hasPrefixes = false
for (const country of countriesList()) {

View file

@ -2,11 +2,10 @@
import { render } from 'solid-js/web'
import { App } from './App'
import { registerServiceWorker } from './sw/register.ts'
import './app.css'
const root = document.getElementById('root')
registerServiceWorker()
// registerServiceWorker()
render(() => <App />, root!)

View file

@ -17,6 +17,7 @@ const buttonVariants = cva(
outline: 'border border-input text-muted-foreground hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
ghostDestructive: 'text-error-foreground hover:bg-error',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {

View file

@ -0,0 +1,7 @@
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -0,0 +1,15 @@
import type { TelegramAccount } from 'mtcute-repl-worker/client'
import { computed } from 'nanostores'
import { linkedAtom } from './link.ts'
export const $accounts = linkedAtom<TelegramAccount[]>('accounts')
export const $activeAccountId = linkedAtom<string>('activeAccountId')
export const $activeAccount = computed([$accounts, $activeAccountId], (accounts, activeAccountId) => {
if (!activeAccountId) return null
const account = accounts.find(account => account.id === activeAccountId)
if (!account) return null
return account
})

View file

@ -0,0 +1,25 @@
import { workerInvoke, workerOn } from 'mtcute-repl-worker/client'
import { atom, type ReadableAtom, type WritableAtom } from 'nanostores'
const linkedAtoms = new Map<string, WritableAtom<any>>()
let registered = false
let isInternalWrite = false
export function linkedAtom<T>(id: string): ReadableAtom<T> & WritableAtom<T> {
if (!registered) {
workerOn('AtomUpdate', ({ id, value }) => {
isInternalWrite = true
linkedAtoms.get(id)?.set(value)
isInternalWrite = false
})
registered = true
}
const store = atom<T>(null!)
store.listen((value) => {
if (isInternalWrite) return
workerInvoke('atom', 'write', { id, value })
})
linkedAtoms.set(id, store)
return store
}

1
packages/repl/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,25 @@
import type { UserConfig } from 'vite'
import { join } from 'node:path'
import { defineConfig, loadEnv } from 'vite'
import solid from 'vite-plugin-solid'
// eslint-disable-next-line import/no-relative-packages
import externalizeDeps from '../../scripts/vite-plugin-externalize-dependencies.ts'
export default defineConfig((env): UserConfig => {
process.env = {
...process.env,
...loadEnv(env.mode, join(__dirname, '../..')),
}
return {
server: {
port: 3000,
},
plugins: [
solid(),
externalizeDeps({
externals: [],
}),
],
}
})

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@mtcute/playground worker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.ts"></script>
</body>
</html>

View file

@ -0,0 +1,29 @@
{
"name": "mtcute-repl-worker",
"type": "module",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@9.5.0",
"exports": {
"./client": "./src/client.ts"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@badrap/valita": "^0.4.2",
"@fuman/fetch": "^0.0.8",
"@fuman/io": "0.0.8",
"@fuman/utils": "0.0.4",
"@mtcute/convert": "^0.19.4",
"@mtcute/web": "^0.19.5",
"@nanostores/persistent": "^0.10.2",
"fflate": "^0.8.2",
"idb": "^8.0.1",
"nanoid": "^5.0.9",
"nanostores": "^0.11.3",
"uqr": "^0.1.2"
}
}

View file

@ -0,0 +1,114 @@
import type * as mtcuteTypes from '@mtcute/web'
import type { ReplWorker } from './worker/main.ts'
import type { ReplWorkerEvents } from './worker/utils.ts'
import { Deferred, unknownToError } from '@fuman/utils'
export type { TelegramAccount } from './store/accounts.ts'
// eslint-disable-next-line ts/no-namespace
export namespace mtcute {
export type RawCountry = mtcuteTypes.tl.help.RawCountry
export type RawCountryCode = mtcuteTypes.tl.help.RawCountryCode
export type SentCode = mtcuteTypes.SentCode
export type ConnectionState = mtcuteTypes.ConnectionState
}
const pending = new Map<number, Deferred<any>>()
const listeners = new Map<string, ((e: any) => void)[]>()
let nextId = 0
let iframe: HTMLIFrameElement
let loadedDeferred: Deferred<void> | undefined
export function workerInit(iframe_: HTMLIFrameElement) {
iframe = iframe_
loadedDeferred = new Deferred()
iframe.addEventListener('error', () => {
loadedDeferred?.reject(new Error('Failed to load worker iframe'))
loadedDeferred = undefined
})
window.addEventListener('message', (e) => {
if (e.source !== iframe.contentWindow) return
if (e.data.event === 'LOADED') {
loadedDeferred?.resolve()
loadedDeferred = undefined
return
}
if (e.data.event) {
const fns = listeners.get(e.data.event)
if (fns) {
for (const fn of fns) {
fn(e.data.data)
}
}
return
}
const { id, result, error } = e.data
const def = pending.get(id)
if (!def) return
if (error) {
def.reject(unknownToError(error))
} else {
def.resolve(result)
}
})
}
type ForceFunction<T> = T extends (...args: any) => any ? T : never
export async function workerInvoke<
Domain extends keyof ReplWorker,
Method extends keyof ReplWorker[Domain],
>(
domain: Domain,
method: Method,
...params: Parameters<ForceFunction<ReplWorker[Domain][Method]>> extends [infer Params] ? [Params] : []
): Promise<ReturnType<ForceFunction<ReplWorker[Domain][Method]>>>
export async function workerInvoke(domain: string, method: string, params?: any) {
if (loadedDeferred) {
await loadedDeferred.promise
}
const id = nextId++
const def = new Deferred<any>()
pending.set(id, def)
let withAbort = false
if (params?.abortSignal) {
const signal = params.abortSignal
signal.addEventListener('abort', () => {
iframe.contentWindow!.postMessage({ id, abort: true }, '*')
def.reject(signal.reason)
pending.delete(id)
})
delete params.abortSignal
withAbort = true
}
iframe.contentWindow!.postMessage({
id,
domain,
method,
params,
withAbort,
}, '*')
return def.promise
}
export function workerOn<Event extends keyof ReplWorkerEvents>(event: Event, listener: (e: ReplWorkerEvents[Event]) => void) {
if (!listeners.has(event)) {
listeners.set(event, [])
}
const arr = listeners.get(event)!
arr.push(listener)
return () => {
const idx = arr.indexOf(listener)
if (idx !== -1) {
arr.splice(idx, 1)
}
}
}

View file

@ -0,0 +1,18 @@
import { registerServiceWorker } from './sw/register.ts'
import { registerWorker, ReplWorker } from './worker/main.ts'
import './store/accounts.ts'
registerServiceWorker()
if (!window.parent || window.parent === window) {
document.querySelector('#root')!.innerHTML = 'This is an internal page used by the mtcute-repl app, and must be loaded in an iframe.'
throw new Error('Not in iframe')
}
if (new URL(document.referrer).origin !== import.meta.env.VITE_HOST_ORIGIN) {
throw new Error(`Invalid origin: this page must be loaded in an iframe from ${import.meta.env.VITE_HOST_ORIGIN}`)
}
const worker = new ReplWorker()
registerWorker(worker)

View file

@ -1,6 +1,6 @@
import * as v from '@badrap/valita'
import { persistentAtom } from '@nanostores/persistent'
import { computed } from 'nanostores'
import { linkAtom } from './link.ts'
export interface TelegramAccount {
id: string
@ -37,13 +37,7 @@ export const $accounts = persistentAtom<TelegramAccount[]>('repl:accounts', [],
return res
},
})
linkAtom($accounts, 'accounts')
export const $activeAccountId = persistentAtom<string>('repl:activeAccountId')
export const $activeAccount = computed([$accounts, $activeAccountId], (accounts, activeAccountId) => {
if (!activeAccountId) return null
const account = accounts.find(account => account.id === activeAccountId)
if (!account) return null
return account
})
linkAtom($activeAccountId, 'activeAccountId')

View file

@ -0,0 +1,22 @@
import type { ReadableAtom, WritableAtom } from 'nanostores'
import { emitEvent } from '../worker/utils.ts'
const linkedAtoms = new Map<string, ReadableAtom<any> & WritableAtom<any>>()
export function linkAtom<T>(atom: ReadableAtom<T> & WritableAtom<T>, id: string) {
atom.subscribe((value) => {
emitEvent('AtomUpdate', { id, value })
})
linkedAtoms.set(id, atom)
}
export function publishLinkedAtoms() {
for (const [id, atom] of linkedAtoms) {
emitEvent('AtomUpdate', { id, value: atom.get() })
}
}
export function writeLinkedAtom<T>(id: string, value: T) {
const atom = linkedAtoms.get(id)
if (!atom) return
atom.set(value)
}

View file

@ -1,8 +1,7 @@
import type { BaseTelegramClient } from '@mtcute/web'
import { downloadAsBuffer, getMe } from '@mtcute/web/methods.js'
import { createInternalClient } from '../lib/telegram.ts'
import { timeout } from '../lib/utils.ts'
import { $accounts } from '../store/accounts.ts'
import { createInternalClient } from '../utils/telegram.ts'
import { timeout } from '../utils/timeout.ts'
import { getCacheStorage } from './cache.ts'
const clients = new Map<string, BaseTelegramClient>()
@ -38,8 +37,10 @@ export async function handleAvatarRequest(accountId: string) {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=86400',
'Access-Control-Allow-Origin': '*',
},
})
await cache.put(cacheKey, res.clone())
return res

View file

@ -1,4 +1,4 @@
import { timeout } from '../lib/utils.ts'
import { timeout } from '../utils/timeout.ts'
let _cacheStorage: Cache | undefined
@ -14,7 +14,6 @@ export async function getCacheStorage() {
export async function requestCache(event: FetchEvent) {
try {
// const cache = await ctx.caches.open(CACHE_ASSETS_NAME);
const cache = await timeout(getCacheStorage(), 10000)
const cachedRes = await timeout(cache.match(event.request), 10000)

View file

@ -9,7 +9,7 @@ let registered = false
let nextId = 0
const pending = new Map<number, Deferred<any>>()
function swInvokeMethod(request: SwMessage) {
export function swInvokeMethod(request: SwMessage) {
const sw = getServiceWorker()
if (!registered) {
navigator.serviceWorker.addEventListener('message', (e) => {
@ -33,15 +33,3 @@ function swInvokeMethod(request: SwMessage) {
sw.postMessage(request)
return def.promise
}
export function swUploadScript(name: string, files: Record<string, string>) {
return swInvokeMethod({ event: 'UPLOAD_SCRIPT', name, files })
}
export function swForgetScript(name: string) {
return swInvokeMethod({ event: 'FORGET_SCRIPT', name })
}
export function swClearCache() {
return swInvokeMethod({ event: 'CLEAR_CACHE' })
}

View file

View file

@ -1,5 +1,3 @@
/// <reference types="vite/client" />
declare module '@mtcute/web?external' {
export * from '@mtcute/web'
}

View file

@ -1,6 +1,6 @@
// eslint-disable-next-line antfu/no-import-dist
import chobitsuUrl from '../../vendor/chobitsu/dist/chobitsu.js?url'
import runnerScriptUrl from '../components/runner/iframe.ts?url'
import chobitsuUrl from '../../../../../vendor/chobitsu/dist/chobitsu.js?url'
import runnerScriptUrl from './script.ts?url'
export async function generateImportMap(packageJsons: any[]) {
const importMap: Record<string, string> = {}

View file

@ -3,6 +3,8 @@ import { TelegramClient } from '@mtcute/web?external'
type ConnectionState = import('@mtcute/web').ConnectionState
type TelegramClientOptions = import('@mtcute/web').TelegramClientOptions
const HOST_ORIGIN = import.meta.env.VITE_HOST_ORIGIN
declare const chobitsu: any
declare const window: typeof globalThis & {
@ -12,7 +14,7 @@ declare const window: typeof globalThis & {
}
function sendToDevtools(message: any) {
window.parent.postMessage({ event: 'TO_DEVTOOLS', value: message }, '*')
window.parent.postMessage({ event: 'TO_DEVTOOLS', value: message }, HOST_ORIGIN)
}
function sendToChobitsu(message: any) {
@ -37,6 +39,7 @@ function initClient(accountId: string) {
if (storedAccounts) {
const accounts = JSON.parse(storedAccounts)
const ourAccount = accounts.find((it: any) => it.id === accountId)
if (!ourAccount) return
if (ourAccount && ourAccount.testMode) {
extraConfig = {
@ -53,7 +56,7 @@ function initClient(accountId: string) {
})
window.tg.onConnectionState.add((state) => {
lastConnectionState = state
window.parent.postMessage({ event: 'CONNECTION_STATE', value: state }, '*')
window.parent.postMessage({ event: 'CONNECTION_STATE', value: state }, HOST_ORIGIN)
})
}
@ -78,7 +81,11 @@ window.addEventListener('message', ({ data }) => {
sendToDevtools({ method: 'DOM.documentUpdated' })
initClient(data.accountId)
window.tg.connect()
window.tg?.connect()
setInterval(() => {
window.parent.postMessage({ event: 'PING' }, HOST_ORIGIN)
}, 500)
} else if (data.event === 'RUN') {
const el = document.createElement('script')
el.type = 'module'
@ -103,7 +110,7 @@ window.addEventListener('message', ({ data }) => {
initClient(data.accountId)
if (lastConnectionState !== 'offline') {
window.parent.postMessage({ event: 'CONNECTION_STATE', value: 'offline' }, '*')
window.parent.postMessage({ event: 'CONNECTION_STATE', value: 'offline' }, HOST_ORIGIN)
window.tg.connect()
}
} else if (data.event === 'DISCONNECT') {
@ -112,7 +119,7 @@ window.addEventListener('message', ({ data }) => {
if (lastAccountId) {
initClient(lastAccountId)
}
window.parent.postMessage({ event: 'CONNECTION_STATE', value: 'offline' }, '*')
window.parent.postMessage({ event: 'CONNECTION_STATE', value: 'offline' }, HOST_ORIGIN)
} else if (data.event === 'RECONNECT') {
window.tg.connect()
}
@ -120,7 +127,7 @@ window.addEventListener('message', ({ data }) => {
window.__handleScriptEnd = (error) => {
if (!window.__currentScript) return
window.parent.postMessage({ event: 'SCRIPT_END', error }, '*')
window.parent.postMessage({ event: 'SCRIPT_END', error }, HOST_ORIGIN)
window.__currentScript.remove()
window.__currentScript = undefined
}

View file

@ -1,5 +1,5 @@
import { unknownToError } from '@fuman/utils'
import { IS_SAFARI } from '../lib/env.ts'
import { IS_SAFARI } from '../utils/env.ts'
import { handleAvatarRequest } from './avatar.ts'
import { requestCache } from './cache.ts'
import { clearCache, forgetScript, handleRuntimeRequest, uploadScript } from './runtime.ts'

View file

@ -1,6 +1,6 @@
import { utf8 } from '@fuman/utils'
import { generateImportMap, generateRunnerHtml } from '../lib/runtime.ts'
import { VfsStorage } from '../lib/vfs/storage.ts'
import { VfsStorage } from '../vfs/storage.ts'
import { generateImportMap, generateRunnerHtml } from './iframe/html.ts'
const libraryCache = new Map<string, Map<string, Uint8Array>>()
let importMapCache: Record<string, string> | undefined
@ -59,9 +59,6 @@ export async function handleRuntimeRequest(url: URL) {
const path = url.pathname.slice('/sw/runtime/'.length)
if (path === '_iframe.html') {
// primarily a workaround for chrome bug: https://crbug.com/880768
// (tldr: iframes created with blob: do not inherit service worker from the parent page)
// but also a nice way to warm up the cache
if (!importMapCache) {
const vfs = await getVfs()
const libNames = (await vfs.getAvailableLibs()).filter(lib => !lib.startsWith('@types/'))

View file

@ -1,11 +1,3 @@
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function timeout<T>(promise: Promise<T>, timeout: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {

View file

@ -1,7 +1,7 @@
import type { VfsFile, VfsStorage } from './storage'
import { read, webReadableToFuman } from '@fuman/io'
import { asyncPool, AsyncQueue, utf8 } from '@fuman/utils'
import { ffetch } from '../ffetch.ts'
import { ffetch } from '../utils/ffetch.ts'
import { GunzipStream } from './gzip.ts'
import { extractTar, type TarEntry } from './tar.ts'
@ -15,7 +15,9 @@ const PACKAGES_TO_SKIP = new Set([
])
export async function getLatestVersions() {
const versions = await fetch('https://raw.githubusercontent.com/mtcute/mtcute/refs/heads/master/scripts/latest-versions.json').then(res => res.json())
const versions = await ffetch(`https://raw.githubusercontent.com/mtcute/mtcute/refs/heads/master/scripts/latest-versions.json?v=${Date.now()}`)
.json<Record<string, string>>()
for (const pkg of Object.keys(versions)) {
if (PACKAGES_TO_SKIP.has(pkg)) {
delete versions[pkg]
@ -148,7 +150,6 @@ export async function downloadNpmPackage(params: {
storage: VfsStorage
progress: (downloaded: number, total: number, file: string) => void
filterFiles?: (file: TarEntry) => boolean
signal: AbortSignal
}) {
const {
packageName,
@ -156,12 +157,11 @@ export async function downloadNpmPackage(params: {
storage,
progress,
filterFiles,
signal,
} = params
const tgzUrl = `https://registry.npmjs.org/${packageName}/-/${packageName.replace(/^.*?\//, '')}-${version}.tgz`
const response = await fetch(tgzUrl, { signal })
const response = await fetch(tgzUrl)
if (!response.ok || !response.body) {
throw new Error(`Failed to download: HTTP ${response.status}`)
}

View file

@ -0,0 +1,69 @@
import { publishLinkedAtoms, writeLinkedAtom } from '../store/link.ts'
import { ReplWorkerSw } from './sw.ts'
import { ReplWorkerTelegram } from './telegram.ts'
import { ReplWorkerVfs } from './vfs.ts'
export class ReplWorker {
readonly telegram = new ReplWorkerTelegram()
readonly vfs = new ReplWorkerVfs()
readonly sw = new ReplWorkerSw()
readonly atom = {
write({ id, value }: { id: string, value: any }) {
writeLinkedAtom(id, value)
},
}
}
const pendingAborts = new Map<number, AbortController>()
export function registerWorker(worker: ReplWorker) {
globalThis.onmessage = async (e) => {
if (e.source !== window.parent) return
if (e.origin !== import.meta.env.VITE_HOST_ORIGIN) {
console.error('Ignoring message from invalid origin', e.origin)
return
}
if (e.data.abort) {
const abortController = pendingAborts.get(e.data.id)
if (abortController) {
abortController.abort()
}
return
}
const {
id,
domain,
method,
params,
withAbort,
} = e.data
if (!(domain in worker) || !(method in (worker as any)[domain])) {
window.parent.postMessage({ id, error: `Method ${domain}.${method} not found` }, '*')
return
}
if (withAbort) {
const abortController = new AbortController()
pendingAborts.set(id, abortController)
params.abortSignal = abortController.signal
}
try {
const result = await (worker as any)[domain][method](params)
window.parent.postMessage({ id, result }, '*')
} catch (error) {
window.parent.postMessage({ id, error }, '*')
}
if (withAbort) {
pendingAborts.delete(id)
}
}
window.parent.postMessage({ event: 'LOADED' }, '*')
publishLinkedAtoms()
}

View file

@ -0,0 +1,16 @@
import { swInvokeMethod } from '../sw/client.ts'
export class ReplWorkerSw {
async uploadScript(params: {
name: string
files: Record<string, string>
}) {
return swInvokeMethod({ event: 'UPLOAD_SCRIPT', name: params.name, files: params.files })
}
async forgetScript(params: {
name: string
}) {
return swInvokeMethod({ event: 'FORGET_SCRIPT', name: params.name })
}
}

View file

@ -0,0 +1,197 @@
import type { BaseTelegramClient, SentCode, User } from '@mtcute/web'
import type { TelegramAccount } from '../store/accounts.ts'
import { assert } from '@fuman/utils'
import { tl } from '@mtcute/web'
import { checkPassword, resendCode, sendCode, signIn, signInQr } from '@mtcute/web/methods.js'
import { renderSVG } from 'uqr'
import { $accounts, $activeAccountId } from '../store/accounts.ts'
import { createInternalClient, deleteAccount } from '../utils/telegram.ts'
import { emitEvent } from './utils.ts'
const clients = new Map<string, BaseTelegramClient>()
function getClient(accountId: string) {
const client = clients.get(accountId)
if (!client) throw new Error('Client not found')
return client
}
async function handleAuthSuccess(accountId: string, user: User) {
const client = getClient(accountId)
const dcs = await client.mt.storage.dcs.fetch()
const dcId = dcs?.main.id ?? 2
const testMode = client.params.testMode ?? false
const account: TelegramAccount = {
id: accountId,
name: user.displayName,
telegramId: user.id,
bot: user.isBot,
testMode,
dcId,
}
$accounts.set([
...$accounts.get(),
account,
])
$activeAccountId.set(accountId)
return account
}
export class ReplWorkerTelegram {
async createClient(params: {
accountId: string
testMode?: boolean
}) {
const client = createInternalClient(params.accountId, params.testMode)
clients.set(params.accountId, client)
}
async disposeClient(params: {
accountId: string
forget?: boolean
}) {
const client = clients.get(params.accountId)
if (!client) return
await client.close()
clients.delete(params.accountId)
if (params.forget) {
await deleteAccount(params.accountId)
}
}
async loadCountries(params: {
accountId: string
}): Promise<{ countries: tl.help.RawCountry[], countryByIp: string }> {
const client = getClient(params.accountId)
const [
countries,
nearestDc,
] = await Promise.all([
client.call({ _: 'help.getCountriesList', langCode: 'en', hash: 0 }),
client.call({ _: 'help.getNearestDc' }),
])
assert(countries._ === 'help.countriesList') // todo caching
return {
countries: countries.countries,
countryByIp: nearestDc.country.toUpperCase(),
}
}
async signInQr(params: {
accountId: string
abortSignal: AbortSignal
}): Promise<TelegramAccount | 'need_password'> {
const { accountId, abortSignal } = params
const client = getClient(accountId)
try {
const user = await signInQr(client, {
abortSignal,
onUrlUpdated: qr => emitEvent('QrCodeUpdate', { accountId, qrCode: renderSVG(qr) }),
onQrScanned: () => emitEvent('QrCodeScanned', { accountId }),
})
return await handleAuthSuccess(accountId, user)
} catch (e) {
if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED')) {
return 'need_password'
} else {
throw e
}
}
}
async sendCode(params: {
accountId: string
phone: string
abortSignal: AbortSignal
}): Promise<SentCode> {
const { accountId, phone, abortSignal } = params
const code = await sendCode(getClient(accountId), {
phone,
abortSignal,
})
return (code as any).toJSON()
}
async resendCode(params: {
accountId: string
phone: string
phoneCodeHash: string
abortSignal: AbortSignal
}): Promise<SentCode> {
const { accountId, phone, phoneCodeHash, abortSignal } = params
const code = await resendCode(getClient(accountId), {
phone,
phoneCodeHash,
abortSignal,
})
return (code as any).toJSON()
}
async signIn(params: {
accountId: string
phone: string
phoneCodeHash: string
phoneCode: string
abortSignal?: AbortSignal
}): Promise<TelegramAccount | 'need_password'> {
const { accountId, phone, phoneCodeHash, phoneCode, abortSignal } = params
try {
const user = await signIn(getClient(accountId), {
phone,
phoneCodeHash,
phoneCode,
abortSignal,
})
return await handleAuthSuccess(accountId, user)
} catch (e) {
if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED')) {
return 'need_password'
} else {
throw e
}
}
}
async checkPassword(params: {
accountId: string
password: string
abortSignal: AbortSignal
}): Promise<TelegramAccount> {
const { accountId, password, abortSignal } = params
try {
const user = await checkPassword(getClient(accountId), {
password,
abortSignal,
})
return await handleAuthSuccess(accountId, user)
} catch (e) {
if (tl.RpcError.is(e, 'PASSWORD_HASH_INVALID')) {
throw new Error('Incorrect password')
} else {
throw e
}
}
}
async fetchAvatar(accountId: string) {
const res = await fetch(`/sw/avatar/${accountId}/avatar.jpg`)
if (!res.ok) {
return null
} else {
return new Uint8Array(await res.arrayBuffer())
}
}
}

View file

@ -0,0 +1,11 @@
export interface ReplWorkerEvents {
UpdateProgress: { progress: number, total: number }
AtomUpdate: { id: string, value: any }
QrCodeUpdate: { accountId: string, qrCode: string }
QrCodeScanned: { accountId: string }
}
export function emitEvent<Event extends keyof ReplWorkerEvents>(event: Event, data: ReplWorkerEvents[Event]) {
window.parent!.postMessage({ event, data }, '*')
}

View file

@ -0,0 +1,67 @@
import { asyncPool } from '@fuman/utils'
import { swInvokeMethod } from '../sw/client.ts'
import { downloadNpmPackage, getLatestVersions, getPackagesToDownload } from '../vfs/downloader.ts'
import { VfsStorage } from '../vfs/storage.ts'
import { emitEvent } from './utils.ts'
let _vfs: VfsStorage | undefined
async function getVfs() {
if (!_vfs) {
_vfs = await VfsStorage.create()
}
return _vfs
}
export class ReplWorkerVfs {
async checkForUpdates(): Promise<Record<string, string>> {
const latestVersions = await getLatestVersions()
const versions = await getPackagesToDownload(latestVersions, await getVfs())
return versions
}
async downloadPackages(packages: Record<string, string>) {
await swInvokeMethod({ event: 'CLEAR_CACHE' })
const vfs = await getVfs()
let downloadedBytes = 0
let totalBytes = 0
await asyncPool(Object.entries(packages), async ([lib, version]) => {
let isFirst = true
let prevDownloaded = 0
function onProgress(downloaded: number, total: number) {
if (isFirst) {
totalBytes += total
isFirst = false
}
const diff = downloaded - prevDownloaded
downloadedBytes += diff
prevDownloaded = downloaded
emitEvent('UpdateProgress', { progress: downloadedBytes, total: totalBytes })
}
await downloadNpmPackage({
packageName: lib,
version,
storage: vfs,
progress: onProgress,
filterFiles: (file) => {
if (!file.header) return true
const name = file.header.name
return !name.endsWith('.cjs') && !name.endsWith('.d.cts') && name !== 'LICENSE' && name !== 'README.md'
},
})
})
}
async getLibraryNames() {
return (await getVfs()).getAvailableLibs()
}
async getLibrary(name: string) {
return (await getVfs()).readLibrary(name)
}
}

View file

@ -0,0 +1,26 @@
import type { UserConfig } from 'vite'
import { join } from 'node:path'
import { defineConfig, loadEnv } from 'vite'
// eslint-disable-next-line import/no-relative-packages
import externalizeDeps from '../../scripts/vite-plugin-externalize-dependencies.ts'
export default defineConfig((env): UserConfig => {
process.env = {
...process.env,
...loadEnv(env.mode, join(__dirname, '../..')),
}
return {
server: {
port: 3001,
},
optimizeDeps: {
exclude: ['@mtcute/wasm'],
},
plugins: [
externalizeDeps({
externals: [],
}),
],
}
})

View file

@ -1,16 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index 4b5eff9ae46b5fa0af75b16ef21f5b93142a7251..bc1651dfd82210b69ec9b5709c5f6a6320ca31b0 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -14,9 +14,9 @@ const r = /* @__PURE__ */ new Set(), s = (n, e) => e.some((t) => typeof t == "st
if (r.size === 0)
return e;
const t = "@id/", u = new RegExp(
- `${n}${t}(${[...r].join(
+ `${n}${t}(${[...r].map(it => it.replace(/\?/g, "\\?")).join(
"|"
- )})`,
+ )})(?:\\?external)?`,
"g"
);
return u.test(e) ? e.replace(

File diff suppressed because it is too large Load diff

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
packages:
- 'packages/*'

View file

@ -1,14 +0,0 @@
import type { TelegramAccount } from '../store/accounts.ts'
import { Avatar, AvatarFallback, AvatarImage, makeAvatarFallbackText } from '../lib/components/ui/avatar.tsx'
export function AccountAvatar(props: {
class?: string
account: TelegramAccount
}) {
return (
<Avatar class={props.class}>
<AvatarImage src={`/sw/avatar/${props.account.id}/avatar.jpg`} />
<AvatarFallback>{makeAvatarFallbackText(props.account.name)}</AvatarFallback>
</Avatar>
)
}

View file

@ -1,99 +0,0 @@
import { asyncPool } from '@fuman/utils'
import { filesize } from 'filesize'
import { createSignal, onCleanup, onMount } from 'solid-js'
import { Spinner } from '../lib/components/ui/spinner.tsx'
import { downloadNpmPackage, getLatestVersions, getPackagesToDownload } from '../lib/vfs/downloader'
import { VfsStorage } from '../lib/vfs/storage'
import { swClearCache } from '../sw/client.ts'
export interface UpdaterProps {
onComplete: (versions: Record<string, string>) => void
}
export function Updater(props: UpdaterProps) {
const [downloadedBytes, setDownloadedBytes] = createSignal(0)
const [totalBytes, setTotalBytes] = createSignal(Infinity)
const [step, setStep] = createSignal('Idle')
let abortController: AbortController | undefined
async function runUpdater() {
if (abortController) abortController.abort()
abortController = new AbortController()
const signal = abortController.signal
setStep('Checking for updates...')
const vfs = await VfsStorage.create()
const latestVersions = await getLatestVersions()
const versions = await getPackagesToDownload(latestVersions, vfs)
if (Object.keys(versions).length === 0) {
props.onComplete(latestVersions)
return
}
const entries = Object.entries(versions)
setStep('Downloading...')
await swClearCache()
await asyncPool(entries, async ([lib, version]) => {
let isFirst = true
let prevDownloaded = 0
function onProgress(downloaded: number, total: number) {
if (isFirst) {
setTotalBytes(prev => prev === Infinity ? total : prev + total)
isFirst = false
}
const diff = downloaded - prevDownloaded
setDownloadedBytes(prev => prev + diff)
prevDownloaded = downloaded
}
await downloadNpmPackage({
packageName: lib,
version,
storage: vfs,
progress: onProgress,
filterFiles: (file) => {
if (!file.header) return true
const name = file.header.name
return !name.endsWith('.cjs') && !name.endsWith('.d.cts') && name !== 'LICENSE' && name !== 'README.md'
},
signal,
})
})
props.onComplete(latestVersions)
}
onMount(() => {
runUpdater()
})
onCleanup(() => {
abortController?.abort()
})
return (
<div class="flex flex-col items-center gap-2 p-4">
<Spinner
class="size-10"
indeterminate
/>
<div class="text-center text-xs text-muted-foreground">
{step()}
{totalBytes() !== Infinity && (
<div>
{filesize(downloadedBytes())}
{' / '}
{filesize(totalBytes())}
</div>
)}
</div>
</div>
)
}

View file

@ -1,98 +0,0 @@
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
import { ChevronDownIcon, ExternalLinkIcon, LucideCheck, SettingsIcon, UsersIcon } from 'lucide-solid'
import { SiGithub } from 'solid-icons/si'
import { For } from 'solid-js'
import { Button } from '../../lib/components/ui/button.tsx'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../../lib/components/ui/dropdown-menu.tsx'
import { cn } from '../../lib/utils.ts'
import { $accounts, $activeAccount, $activeAccountId } from '../../store/accounts.ts'
import { useStore } from '../../store/use-store.ts'
import { AccountAvatar } from '../AccountAvatar.tsx'
export function NavbarMenu(props: {
onShowAccounts: () => void
onShowSettings: () => void
}) {
const activeAccount = useStore($activeAccount)
const accounts = useStore($accounts)
return (
<DropdownMenu>
<DropdownMenuTrigger
as={(props: DropdownMenuTriggerProps) => (
<Button
class="z-10 ml-auto px-2"
variant="ghost"
{...props}
>
{activeAccount() != null && (
<>
<AccountAvatar class="size-6" account={activeAccount()!} />
<span class="ml-2 max-w-[120px] truncate">
{activeAccount()!.name}
</span>
<ChevronDownIcon class="ml-2 size-4" />
</>
)}
</Button>
)}
/>
<DropdownMenuContent class="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuGroupLabel>Accounts</DropdownMenuGroupLabel>
<For each={accounts()}>
{account => (
<DropdownMenuItem onClick={() => $activeAccountId.set(account.id)}>
<AccountAvatar
class={cn(
'size-4',
account.id !== activeAccount()?.id && 'opacity-50',
)}
account={account}
/>
<span
class={cn(
'ml-2',
account.id === activeAccount()?.id ? 'font-semibold' : 'text-muted-foreground',
)}
>
{account.name}
</span>
{account.id === activeAccount()?.id && <LucideCheck class="ml-auto size-4" />}
</DropdownMenuItem>
)}
</For>
<DropdownMenuItem onClick={props.onShowAccounts}>
<UsersIcon class="mr-2 size-4" />
Manage accounts
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={props.onShowSettings}>
<SettingsIcon class="mr-2 size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
as="a"
class="cursor-pointer"
href="https://github.com/mtcute/repl"
target="_blank"
>
<SiGithub class="mr-2 size-4" />
GitHub
<ExternalLinkIcon class="ml-auto size-4" />
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -1,124 +0,0 @@
// roughly based on https://github.com/microsoft/TypeScript-Website/blob/v2/packages/typescript-vfs/src/index.ts
import type { CompilerHost, CompilerOptions, SourceFile, System } from 'typescript'
import type { VfsStorage } from './storage'
import { iter, utf8 } from '@fuman/utils'
function notImplemented(methodName: string): any {
throw new Error(`Method '${methodName}' is not implemented.`)
}
// "/DOM.d.ts" => "/lib.dom.d.ts"
const libize = (path: string) => path.replace('/', '/lib.').toLowerCase()
export function createSystem(files: Map<string, Uint8Array>): System {
return {
args: [],
createDirectory: () => notImplemented('createDirectory'),
// TODO: could make a real file tree
directoryExists: (directory) => {
return Array.from(files.keys()).some(path => path.startsWith(directory))
},
exit: () => notImplemented('exit'),
fileExists: fileName => files.has(fileName) || files.has(libize(fileName)),
getCurrentDirectory: () => '/',
getDirectories: () => [],
getExecutingFilePath: () => notImplemented('getExecutingFilePath'),
readDirectory: (directory) => {
return (directory === '/' ? Array.from(files.keys()) : [])
},
readFile: (fileName) => {
const bytes = files.get(fileName) ?? files.get(libize(fileName))
if (!bytes) return undefined
return utf8.decoder.decode(bytes)
},
resolvePath: path => path,
newLine: '\n',
useCaseSensitiveFileNames: true,
write: () => notImplemented('write'),
writeFile: (fileName, contents) => {
files.set(fileName, utf8.encoder.encode(contents))
},
deleteFile: (fileName) => {
files.delete(fileName)
},
}
}
export function createVirtualCompilerHost(
sys: System,
compilerOptions: CompilerOptions,
ts: typeof import('typescript'),
) {
const sourceFiles = new Map<string, SourceFile>()
const save = (sourceFile: SourceFile) => {
sourceFiles.set(sourceFile.fileName, sourceFile)
return sourceFile
}
interface Return {
compilerHost: CompilerHost
updateFile: (sourceFile: SourceFile) => boolean
deleteFile: (sourceFile: SourceFile) => boolean
}
const vHost: Return = {
compilerHost: {
...sys,
getCanonicalFileName: fileName => fileName,
getDefaultLibFileName: () => '/lib.d.ts',
// getDefaultLibLocation: () => '/',
getNewLine: () => sys.newLine,
getSourceFile: (fileName, languageVersionOrOptions) => {
const existing = sourceFiles.get(fileName)
if (existing) return existing
const content = sys.readFile(fileName)
if (!content) return undefined
return save(
ts.createSourceFile(
fileName,
content,
languageVersionOrOptions ?? compilerOptions.target ?? ts.ScriptTarget.ESNext,
false,
),
)
},
useCaseSensitiveFileNames: () => sys.useCaseSensitiveFileNames,
},
updateFile: (sourceFile) => {
const alreadyExists = sourceFiles.has(sourceFile.fileName)
sys.writeFile(sourceFile.fileName, sourceFile.text)
sourceFiles.set(sourceFile.fileName, sourceFile)
return alreadyExists
},
deleteFile: (sourceFile) => {
const alreadyExists = sourceFiles.has(sourceFile.fileName)
sourceFiles.delete(sourceFile.fileName)
sys.deleteFile!(sourceFile.fileName)
return alreadyExists
},
}
return vHost
}
export async function createFilesMapFromVfs(vfs: VfsStorage, libs: string[]): Promise<Map<string, Uint8Array>> {
const files = await Promise.all(libs.map(lib => vfs.readLibrary(lib)))
const fileMap = new Map<string, Uint8Array>()
for (const [idx, list] of iter.enumerate(files)) {
const lib = libs[idx].split(':')[1]
const prefix = `/node_modules/${lib}`
for (const file of list) {
const fullPath = prefix + (file.path[0] === '/' ? file.path : `/${file.path}`)
fileMap.set(fullPath, file.contents)
}
}
return fileMap
}

View file

@ -27,5 +27,7 @@
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src"]
"include": [
"packages/*/src"
]
}

View file

@ -18,5 +18,7 @@
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["vite.config.ts"]
"include": [
"**/vite.config.ts"
]
}

View file

@ -1,15 +0,0 @@
import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid'
import externalizeDeps from './scripts/vite-plugin-externalize-dependencies.ts'
export default defineConfig({
optimizeDeps: {
exclude: ['@mtcute/wasm'],
},
plugins: [
solid(),
externalizeDeps({
externals: [],
}),
],
})