refactor: initial move to a multi-origin architecture
This commit is contained in:
parent
f21a53f088
commit
85ef557149
97 changed files with 1426 additions and 1310 deletions
|
@ -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'])
|
||||
})
|
||||
|
|
49
package.json
49
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
41
packages/repl/package.json
Normal file
41
packages/repl/package.json
Normal 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"
|
||||
}
|
||||
}
|
|
@ -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 ? (
|
||||
<Updater
|
||||
onComplete={setVersions}
|
||||
/>
|
||||
) : (
|
||||
<Show
|
||||
when={!updating()}
|
||||
fallback={(
|
||||
<Updater
|
||||
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()}
|
31
packages/repl/src/components/AccountAvatar.tsx
Normal file
31
packages/repl/src/components/AccountAvatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
58
packages/repl/src/components/Updater.tsx
Normal file
58
packages/repl/src/components/Updater.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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`))
|
|
@ -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
|
109
packages/repl/src/components/nav/NavbarMenu.tsx
Normal file
109
packages/repl/src/components/nav/NavbarMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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);
|
|
@ -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,55 +168,54 @@ 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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleRun}
|
||||
disabled={!runnerLoaded() || running()}
|
||||
>
|
||||
<LucidePlay
|
||||
class="mr-2 size-3"
|
||||
/>
|
||||
Run
|
||||
</Button>
|
||||
{dead() ? (
|
||||
<Button
|
||||
variant="ghostDestructive"
|
||||
size="xs"
|
||||
onClick={recreateIframe}
|
||||
>
|
||||
<LucideSkull
|
||||
class="mr-2 size-3"
|
||||
/>
|
||||
Terminate
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleRun}
|
||||
disabled={!runnerLoaded() || running()}
|
||||
>
|
||||
<LucidePlay
|
||||
class="mr-2 size-3"
|
||||
/>
|
||||
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>
|
|
@ -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,14 +209,17 @@ export function AccountsTab() {
|
|||
fallback={(
|
||||
<div class="flex h-full flex-col items-center justify-center gap-4 text-muted-foreground">
|
||||
No accounts yet
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddAccount}
|
||||
>
|
||||
<LucidePlus class="mr-2 size-4" />
|
||||
Log in
|
||||
</Button>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddAccount}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)
|
|
@ -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: {
|
|
@ -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>
|
||||
)}
|
|
@ -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
|
|
@ -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, {
|
||||
phone: phone(),
|
||||
abortSignal: abortController.signal,
|
||||
}).then((code) => {
|
||||
|
||||
try {
|
||||
const code = await workerInvoke('telegram', 'sendCode', {
|
||||
accountId: props.accountId,
|
||||
phone: phone(),
|
||||
abortSignal: abortController.signal,
|
||||
})
|
||||
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, {
|
||||
phone: props.ctx.phone,
|
||||
phoneCodeHash: props.ctx.code.phoneCodeHash,
|
||||
phoneCode: otp(),
|
||||
abortSignal: abortController.signal,
|
||||
}).then((user) => {
|
||||
setLoading(false)
|
||||
props.setStep('done', { user })
|
||||
}).catch((e) => {
|
||||
|
||||
try {
|
||||
const account = await workerInvoke('telegram', 'signIn', {
|
||||
accountId: props.accountId,
|
||||
phone: props.ctx.phone,
|
||||
phoneCodeHash: props.ctx.code.phoneCodeHash,
|
||||
phoneCode: otp(),
|
||||
abortSignal: abortController.signal,
|
||||
})
|
||||
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, {
|
||||
phone: props.ctx.phone,
|
||||
phoneCodeHash: props.ctx.code.phoneCodeHash,
|
||||
abortSignal: abortController.signal,
|
||||
}).then((code) => {
|
||||
try {
|
||||
const code = await workerInvoke('telegram', 'resendCode', {
|
||||
accountId: props.accountId,
|
||||
phone: props.ctx.phone,
|
||||
phoneCodeHash: props.ctx.code.phoneCodeHash,
|
||||
abortSignal: abortController.signal,
|
||||
})
|
||||
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 })
|
||||
})
|
||||
.catch((e) => {
|
||||
setLoading(false)
|
||||
if (tl.RpcError.is(e, 'PASSWORD_HASH_INVALID')) {
|
||||
setError('Incorrect password')
|
||||
} else {
|
||||
setError(e.message)
|
||||
}
|
||||
try {
|
||||
const user = await workerInvoke('telegram', 'checkPassword', {
|
||||
accountId: props.accountId,
|
||||
password: password(),
|
||||
abortSignal: abortController.signal,
|
||||
})
|
||||
props.setStep('done', { account: user })
|
||||
} catch (e) {
|
||||
setLoading(false)
|
||||
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!}
|
||||
/>
|
|
@ -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,35 +23,30 @@ 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()) {
|
||||
setChosenCode(mapCountryCode(country, country.countryCodes[0]))
|
||||
setInputValue(`+${country.countryCodes[0].countryCode} `)
|
||||
break
|
||||
}
|
||||
if (inputValue() === '+') {
|
||||
// guess the country code
|
||||
for (const country of countries) {
|
||||
if (country.iso2 === countryByIp) {
|
||||
setChosenCode(mapCountryCode(country, country.countryCodes[0]))
|
||||
setInputValue(`+${country.countryCodes[0].countryCode} `)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleInput = (e: InputEvent) => {
|
||||
|
@ -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()) {
|
|
@ -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!)
|
|
@ -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: {
|
7
packages/repl/src/lib/utils.ts
Normal file
7
packages/repl/src/lib/utils.ts
Normal 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))
|
||||
}
|
15
packages/repl/src/store/accounts.ts
Normal file
15
packages/repl/src/store/accounts.ts
Normal 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
|
||||
})
|
25
packages/repl/src/store/link.ts
Normal file
25
packages/repl/src/store/link.ts
Normal 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
1
packages/repl/src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
25
packages/repl/vite.config.ts
Normal file
25
packages/repl/vite.config.ts
Normal 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: [],
|
||||
}),
|
||||
],
|
||||
}
|
||||
})
|
13
packages/worker/index.html
Normal file
13
packages/worker/index.html
Normal 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>
|
29
packages/worker/package.json
Normal file
29
packages/worker/package.json
Normal 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"
|
||||
}
|
||||
}
|
114
packages/worker/src/client.ts
Normal file
114
packages/worker/src/client.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
18
packages/worker/src/index.ts
Normal file
18
packages/worker/src/index.ts
Normal 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)
|
|
@ -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')
|
22
packages/worker/src/store/link.ts
Normal file
22
packages/worker/src/store/link.ts
Normal 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)
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
|
|
@ -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' })
|
||||
}
|
0
packages/worker/src/sw/iframe.ts
Normal file
0
packages/worker/src/sw/iframe.ts
Normal file
|
@ -1,5 +1,3 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '@mtcute/web?external' {
|
||||
export * from '@mtcute/web'
|
||||
}
|
|
@ -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> = {}
|
|
@ -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
|
||||
}
|
|
@ -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'
|
|
@ -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/'))
|
|
@ -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(() => {
|
|
@ -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}`)
|
||||
}
|
69
packages/worker/src/worker/main.ts
Normal file
69
packages/worker/src/worker/main.ts
Normal 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()
|
||||
}
|
16
packages/worker/src/worker/sw.ts
Normal file
16
packages/worker/src/worker/sw.ts
Normal 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 })
|
||||
}
|
||||
}
|
197
packages/worker/src/worker/telegram.ts
Normal file
197
packages/worker/src/worker/telegram.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
11
packages/worker/src/worker/utils.ts
Normal file
11
packages/worker/src/worker/utils.ts
Normal 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 }, '*')
|
||||
}
|
67
packages/worker/src/worker/vfs.ts
Normal file
67
packages/worker/src/worker/vfs.ts
Normal 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)
|
||||
}
|
||||
}
|
26
packages/worker/vite.config.ts
Normal file
26
packages/worker/vite.config.ts
Normal 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: [],
|
||||
}),
|
||||
],
|
||||
}
|
||||
})
|
|
@ -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(
|
641
pnpm-lock.yaml
641
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
@ -0,0 +1,2 @@
|
|||
packages:
|
||||
- 'packages/*'
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -27,5 +27,7 @@
|
|||
"isolatedModules": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": [
|
||||
"packages/*/src"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -18,5 +18,7 @@
|
|||
"isolatedModules": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": [
|
||||
"**/vite.config.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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: [],
|
||||
}),
|
||||
],
|
||||
})
|
Loading…
Reference in a new issue