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 antfu from '@antfu/eslint-config'
|
||||||
import tailwind from 'eslint-plugin-tailwindcss'
|
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({
|
export default antfu({
|
||||||
ignores: [
|
ignores: [
|
||||||
'src/components/Editor/utils/*.json',
|
'src/components/Editor/utils/*.json',
|
||||||
|
@ -10,6 +19,7 @@ export default antfu({
|
||||||
solid: true,
|
solid: true,
|
||||||
yaml: false,
|
yaml: false,
|
||||||
rules: {
|
rules: {
|
||||||
|
'node/prefer-global/process': 'off',
|
||||||
'style/multiline-ternary': 'off',
|
'style/multiline-ternary': 'off',
|
||||||
'curly': ['error', 'multi-line'],
|
'curly': ['error', 'multi-line'],
|
||||||
'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
|
'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
|
||||||
|
@ -21,8 +31,9 @@ export default antfu({
|
||||||
'ts/no-redeclare': 'off',
|
'ts/no-redeclare': 'off',
|
||||||
'unused-imports/no-unused-imports': 'error',
|
'unused-imports/no-unused-imports': 'error',
|
||||||
'ts/no-empty-object-type': 'off',
|
'ts/no-empty-object-type': 'off',
|
||||||
|
...mappedTailwindConfig,
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
tw: tailwind,
|
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",
|
"type": "module",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@9.5.0",
|
"packageManager": "pnpm@9.5.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "pnpm -r --parallel dev"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^3.13.0",
|
"@antfu/eslint-config": "^3.13.0",
|
||||||
"@catppuccin/vscode": "^3.16.0",
|
"@catppuccin/vscode": "^3.16.0",
|
||||||
|
"@fuman/fetch": "^0.0.8",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.10.5",
|
||||||
"@types/semver": "^7.5.8",
|
"@types/semver": "^7.5.8",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"esbuild": "^0.24.2",
|
||||||
"eslint-plugin-solid": "^0.14.5",
|
"eslint-plugin-solid": "^0.14.5",
|
||||||
"eslint-plugin-tailwindcss": "^3.17.5",
|
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||||
"monaco-vscode-textmate-theme-converter": "^0.1.7",
|
"monaco-vscode-textmate-theme-converter": "^0.1.7",
|
||||||
"plist2": "^1.1.4",
|
"plist2": "^1.1.4",
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^5.4.11",
|
"vite": "^5.4.11",
|
||||||
"vite-plugin-solid": "^2.11.0"
|
"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" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>@mtcute/repl</title>
|
<title>@mtcute/playground</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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 { workerInit } from 'mtcute-repl-worker/client'
|
||||||
import { createSignal, lazy } from 'solid-js'
|
|
||||||
|
|
||||||
|
import { createSignal, lazy, onMount, Show } from 'solid-js'
|
||||||
import { EditorTabs } from './components/editor/EditorTabs.tsx'
|
import { EditorTabs } from './components/editor/EditorTabs.tsx'
|
||||||
import { NavbarMenu } from './components/nav/NavbarMenu.tsx'
|
import { NavbarMenu } from './components/nav/NavbarMenu.tsx'
|
||||||
import { Runner } from './components/runner/Runner.tsx'
|
import { Runner } from './components/runner/Runner.tsx'
|
||||||
import { SettingsDialog, type SettingsTab } from './components/settings/Settings.tsx'
|
import { SettingsDialog, type SettingsTab } from './components/settings/Settings.tsx'
|
||||||
import { Updater } from './components/Updater.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'))
|
const Editor = lazy(() => import('./components/editor/Editor.tsx'))
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [versions, setVersions] = createSignal<Record<string, string> | undefined>(undefined)
|
const [updating, setUpdating] = createSignal(true)
|
||||||
const [showSettings, setShowSettings] = createSignal(false)
|
const [showSettings, setShowSettings] = createSignal(false)
|
||||||
const [settingsTab, setSettingsTab] = createSignal<SettingsTab>('accounts')
|
const [settingsTab, setSettingsTab] = createSignal<SettingsTab>('accounts')
|
||||||
|
|
||||||
|
let workerIframe!: HTMLIFrameElement
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
workerInit(workerIframe)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-screen w-screen flex-col overflow-hidden">
|
<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">
|
<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">
|
<h1 class="font-mono text-base">
|
||||||
@mtcute/
|
@mtcute/
|
||||||
|
@ -34,11 +45,14 @@ export function App() {
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="h-px shrink-0 bg-border" />
|
<div class="h-px shrink-0 bg-border" />
|
||||||
{versions() === undefined ? (
|
<Show
|
||||||
|
when={!updating()}
|
||||||
|
fallback={(
|
||||||
<Updater
|
<Updater
|
||||||
onComplete={setVersions}
|
onComplete={() => setUpdating(false)}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
|
>
|
||||||
<Resizable orientation="horizontal" class="size-full max-h-[calc(100vh-57px)]">
|
<Resizable orientation="horizontal" class="size-full max-h-[calc(100vh-57px)]">
|
||||||
<ResizablePanel class="h-full overflow-x-auto overflow-y-hidden" minSize={0.2}>
|
<ResizablePanel class="h-full overflow-x-auto overflow-y-hidden" minSize={0.2}>
|
||||||
<EditorTabs />
|
<EditorTabs />
|
||||||
|
@ -52,7 +66,7 @@ export function App() {
|
||||||
<Runner />
|
<Runner />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</Resizable>
|
</Resizable>
|
||||||
)}
|
</Show>
|
||||||
|
|
||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
show={showSettings()}
|
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 { editor as mEditor, Uri } from 'monaco-editor'
|
||||||
import { createEffect, on, onMount } from 'solid-js'
|
import { createEffect, on, onMount } from 'solid-js'
|
||||||
import { useColorScheme } from '../../lib/use-color-scheme'
|
import { useColorScheme } from '../../lib/use-color-scheme'
|
||||||
import { VfsStorage } from '../../lib/vfs/storage.ts'
|
|
||||||
|
|
||||||
import { $activeTab, $tabs, type EditorTab } from '../../store/tabs.ts'
|
import { $activeTab, $tabs, type EditorTab } from '../../store/tabs.ts'
|
||||||
import { useStore } from '../../store/use-store.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>()
|
const modelsByTab = new Map<string, mEditor.ITextModel>()
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const vfs = await VfsStorage.create()
|
|
||||||
|
|
||||||
editor = mEditor.create(ref, {
|
editor = mEditor.create(ref, {
|
||||||
model: null,
|
model: null,
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
|
@ -76,7 +73,7 @@ export default function Editor(props: EditorProps) {
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
await setupMonaco(vfs)
|
await setupMonaco()
|
||||||
|
|
||||||
for (const tab of tabs()) {
|
for (const tab of tabs()) {
|
||||||
const model = mEditor.createModel(tab.main ? DEFAULT_CODE : '', 'typescript', Uri.parse(`file:///${tab.id}.ts`))
|
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 { 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 { editor, languages } from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
|
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
|
||||||
import { wireTmGrammars } from 'monaco-editor-textmate'
|
|
||||||
import { Registry } from 'monaco-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 onigasmWasm from 'onigasm/lib/onigasm.wasm?url'
|
||||||
import TypeScriptWorker from './custom-worker.ts?worker'
|
import TypeScriptWorker from './custom-worker.ts?worker'
|
||||||
import latte from './latte.json'
|
import latte from './latte.json'
|
||||||
|
@ -57,18 +57,19 @@ const compilerOptions: languages.typescript.CompilerOptions = {
|
||||||
languages.typescript.typescriptDefaults.setCompilerOptions(compilerOptions)
|
languages.typescript.typescriptDefaults.setCompilerOptions(compilerOptions)
|
||||||
languages.typescript.javascriptDefaults.setCompilerOptions(compilerOptions)
|
languages.typescript.javascriptDefaults.setCompilerOptions(compilerOptions)
|
||||||
|
|
||||||
export async function setupMonaco(vfs: VfsStorage) {
|
export async function setupMonaco() {
|
||||||
if (!loadingWasm) loadingWasm = loadWASM(onigasmWasm)
|
if (!loadingWasm) loadingWasm = loadWASM(onigasmWasm)
|
||||||
await loadingWasm
|
await loadingWasm
|
||||||
|
|
||||||
const libs = await vfs.getAvailableLibs()
|
const libs = await workerInvoke('vfs', 'getLibraryNames')
|
||||||
const extraLibs: {
|
const extraLibs: {
|
||||||
content: string
|
content: string
|
||||||
filePath?: string
|
filePath?: string
|
||||||
}[] = []
|
}[] = []
|
||||||
|
|
||||||
await asyncPool(libs, async (lib) => {
|
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) {
|
for (const file of files) {
|
||||||
const { path, contents } = file
|
const { path, contents } = file
|
||||||
if (!path.endsWith('.d.ts')) continue
|
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 = `
|
const INJECTED_SCRIPT = `
|
||||||
async function waitForElement(selector, container) {
|
async function waitForElement(selector, container, waitForShadowRoot = false) {
|
||||||
let tabbedPane;
|
let el;
|
||||||
while(!tabbedPane) {
|
while(!el) {
|
||||||
tabbedPane = container.querySelector(selector);
|
el = container.querySelector(selector);
|
||||||
if (!tabbedPane) await new Promise(resolve => setTimeout(resolve, 50));
|
if (!el || (waitForShadowRoot && !el.shadowRoot)) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
}
|
}
|
||||||
return tabbedPane;
|
}
|
||||||
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideBySelector(root, selector) {
|
function hideBySelector(root, selector) {
|
||||||
|
@ -28,8 +30,8 @@ function hideBySelector(root, selector) {
|
||||||
el.style.display = 'none';
|
el.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusConsole(tabbedPane) {
|
async function focusConsole(tabbedPane) {
|
||||||
const consoleTab = tabbedPane.shadowRoot.querySelector('#tab-console');
|
const consoleTab = await waitForElement('#tab-console', tabbedPane.shadowRoot);
|
||||||
|
|
||||||
// tabs get focused on mousedown instead of click
|
// tabs get focused on mousedown instead of click
|
||||||
consoleTab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
consoleTab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||||
|
@ -38,7 +40,7 @@ function focusConsole(tabbedPane) {
|
||||||
|
|
||||||
(async ()=> {
|
(async ()=> {
|
||||||
const tabbedPane = await waitForElement('.tabbed-pane', document.body);
|
const tabbedPane = await waitForElement('.tabbed-pane', document.body);
|
||||||
focusConsole(tabbedPane);
|
await focusConsole(tabbedPane);
|
||||||
hideBySelector(tabbedPane, '.tabbed-pane-header');
|
hideBySelector(tabbedPane, '.tabbed-pane-header');
|
||||||
|
|
||||||
const consoleToolbar = await waitForElement('.console-main-toolbar', document.body);
|
const consoleToolbar = await waitForElement('.console-main-toolbar', document.body);
|
|
@ -1,8 +1,10 @@
|
||||||
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
|
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
|
||||||
import type { ConnectionState } from '@mtcute/web'
|
|
||||||
import type { CustomTypeScriptWorker } from '../editor/utils/custom-worker.ts'
|
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 { 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 { nanoid } from 'nanoid'
|
||||||
import { createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
|
import { createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
|
||||||
import { Button } from '../../lib/components/ui/button.tsx'
|
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 { $activeAccountId } from '../../store/accounts.ts'
|
||||||
import { $tabs } from '../../store/tabs.ts'
|
import { $tabs } from '../../store/tabs.ts'
|
||||||
import { useStore } from '../../store/use-store.ts'
|
import { useStore } from '../../store/use-store.ts'
|
||||||
import { swForgetScript, swUploadScript } from '../../sw/client.ts'
|
|
||||||
import { Devtools } from './Devtools.tsx'
|
import { Devtools } from './Devtools.tsx'
|
||||||
|
|
||||||
|
const $disconnectAfterSecs = persistentAtom('repl:disconnectAfterSecs', 60, {
|
||||||
|
encode: String,
|
||||||
|
decode: Number,
|
||||||
|
})
|
||||||
|
|
||||||
export function Runner() {
|
export function Runner() {
|
||||||
const [devtoolsIframe, setDevtoolsIframe] = createSignal<HTMLIFrameElement | undefined>()
|
const [devtoolsIframe, setDevtoolsIframe] = createSignal<HTMLIFrameElement | undefined>()
|
||||||
|
const [runnerIframe, setRunnerIframe] = createSignal<HTMLIFrameElement>()
|
||||||
const [runnerLoaded, setRunnerLoaded] = createSignal(false)
|
const [runnerLoaded, setRunnerLoaded] = createSignal(false)
|
||||||
const [running, setRunning] = 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 currentAccountId = useStore($activeAccountId)
|
||||||
|
const disconnectAfterSecs = useStore($disconnectAfterSecs)
|
||||||
|
|
||||||
let currentScriptId: string | undefined
|
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) {
|
function handleMessage(e: MessageEvent) {
|
||||||
if (e.source === runnerIframeRef.contentWindow) {
|
if (e.source === runnerIframe()?.contentWindow) {
|
||||||
// event from runner iframe
|
// event from runner iframe
|
||||||
switch (e.data.event) {
|
switch (e.data.event) {
|
||||||
case 'TO_DEVTOOLS': {
|
case 'TO_DEVTOOLS': {
|
||||||
|
@ -35,12 +89,22 @@ export function Runner() {
|
||||||
}
|
}
|
||||||
case 'SCRIPT_END': {
|
case 'SCRIPT_END': {
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
swForgetScript(currentScriptId!)
|
workerInvoke('sw', 'forgetScript', { name: currentScriptId! })
|
||||||
currentScriptId = undefined
|
currentScriptId = undefined
|
||||||
|
rescheduleInactivityTimer()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'CONNECTION_STATE': {
|
case 'CONNECTION_STATE': {
|
||||||
setConnectionState(e.data.value)
|
setConnectionState(e.data.value)
|
||||||
|
if (e.data.value === 'connected') {
|
||||||
|
rescheduleInactivityTimer()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'PING': {
|
||||||
|
if (deadTimer) {
|
||||||
|
timers.clearTimeout(deadTimer)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -62,7 +126,11 @@ export function Runner() {
|
||||||
return
|
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) => {
|
createEffect(on(currentAccountId, (accountId) => {
|
||||||
if (!runnerLoaded()) return
|
if (!runnerLoaded()) return
|
||||||
runnerIframeRef.contentWindow!.postMessage({
|
runnerIframe()!.contentWindow!.postMessage({
|
||||||
event: 'ACCOUNT_CHANGED',
|
event: 'ACCOUNT_CHANGED',
|
||||||
accountId,
|
accountId,
|
||||||
}, '*')
|
}, '*')
|
||||||
|
@ -100,44 +168,42 @@ export function Runner() {
|
||||||
}
|
}
|
||||||
|
|
||||||
currentScriptId = nanoid()
|
currentScriptId = nanoid()
|
||||||
await swUploadScript(currentScriptId, files)
|
await workerInvoke('sw', 'uploadScript', { name: currentScriptId, files })
|
||||||
|
|
||||||
runnerIframeRef.contentWindow!.postMessage({
|
runnerIframe()!.contentWindow!.postMessage({
|
||||||
event: 'RUN',
|
event: 'RUN',
|
||||||
scriptId: currentScriptId,
|
scriptId: currentScriptId,
|
||||||
exports,
|
exports,
|
||||||
}, '*')
|
}, '*')
|
||||||
setRunning(true)
|
setRunning(true)
|
||||||
|
timers.clearTimeout(inactivityTimer)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDisconnect() {
|
function handleDisconnect() {
|
||||||
runnerIframeRef.contentWindow!.postMessage({ event: 'DISCONNECT' }, '*')
|
runnerIframe()?.contentWindow!.postMessage({ event: 'DISCONNECT' }, '*')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleConnect() {
|
function handleConnect() {
|
||||||
runnerIframeRef.contentWindow!.postMessage({ event: 'RECONNECT' }, '*')
|
runnerIframe()?.contentWindow!.postMessage({ event: 'RECONNECT' }, '*')
|
||||||
}
|
|
||||||
|
|
||||||
function handleRestart() {
|
|
||||||
runnerIframeRef.contentWindow!.location.reload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="flex shrink-0 flex-row p-1">
|
<div class="flex shrink-0 flex-row p-1">
|
||||||
<iframe
|
<div ref={iframeContainerRef} />
|
||||||
class="invisible size-0"
|
|
||||||
src="/sw/runtime/_iframe.html"
|
|
||||||
ref={runnerIframeRef}
|
|
||||||
onLoad={() => {
|
|
||||||
runnerIframeRef.contentWindow!.postMessage({
|
|
||||||
event: 'INIT',
|
|
||||||
accountId: currentAccountId(),
|
|
||||||
}, '*')
|
|
||||||
setRunnerLoaded(true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div class="flex w-full grow-0 flex-row">
|
<div class="flex w-full grow-0 flex-row">
|
||||||
|
{dead() ? (
|
||||||
|
<Button
|
||||||
|
variant="ghostDestructive"
|
||||||
|
size="xs"
|
||||||
|
onClick={recreateIframe}
|
||||||
|
>
|
||||||
|
<LucideSkull
|
||||||
|
class="mr-2 size-3"
|
||||||
|
/>
|
||||||
|
Terminate
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
@ -149,6 +215,7 @@ export function Runner() {
|
||||||
/>
|
/>
|
||||||
Run
|
Run
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
|
@ -194,7 +261,7 @@ export function Runner() {
|
||||||
{connectionState() === 'offline' ? 'Connect' : 'Disconnect'}
|
{connectionState() === 'offline' ? 'Connect' : 'Disconnect'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={handleRestart}
|
onClick={recreateIframe}
|
||||||
class="text-xs"
|
class="text-xs"
|
||||||
>
|
>
|
||||||
<LucideRefreshCw
|
<LucideRefreshCw
|
||||||
|
@ -207,18 +274,21 @@ export function Runner() {
|
||||||
<DropdownMenuGroupLabel class="text-xs">
|
<DropdownMenuGroupLabel class="text-xs">
|
||||||
Auto-disconnect after
|
Auto-disconnect after
|
||||||
</DropdownMenuGroupLabel>
|
</DropdownMenuGroupLabel>
|
||||||
<DropdownMenuItem class="text-xs">
|
<DropdownMenuItem class="text-xs" onClick={() => setInactivityTimeout(60)}>
|
||||||
1 minute
|
1 minute
|
||||||
<LucideCheck class="ml-auto size-3" />
|
{disconnectAfterSecs() === 60 && <LucideCheck class="ml-auto size-3" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem class="text-xs">
|
<DropdownMenuItem class="text-xs" onClick={() => setInactivityTimeout(300)}>
|
||||||
5 minutes
|
5 minutes
|
||||||
|
{disconnectAfterSecs() === 300 && <LucideCheck class="ml-auto size-3" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem class="text-xs">
|
<DropdownMenuItem class="text-xs" onClick={() => setInactivityTimeout(1500)}>
|
||||||
15 minutes
|
15 minutes
|
||||||
|
{disconnectAfterSecs() === 1500 && <LucideCheck class="ml-auto size-3" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem class="text-xs">
|
<DropdownMenuItem class="text-xs" onClick={() => setInactivityTimeout(-1)}>
|
||||||
Never
|
Never
|
||||||
|
{disconnectAfterSecs() === -1 && <LucideCheck class="ml-auto size-3" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
|
@ -1,6 +1,5 @@
|
||||||
import type { BaseTelegramClient, User } from '@mtcute/web'
|
import type { TelegramAccount } from 'mtcute-repl-worker/client'
|
||||||
import type { TelegramAccount } from '../../store/accounts.ts'
|
import type { LoginStep } from './login/Login.tsx'
|
||||||
import type { LoginStep, StepContext } from '../login/Login.tsx'
|
|
||||||
import { timers } from '@fuman/utils'
|
import { timers } from '@fuman/utils'
|
||||||
import {
|
import {
|
||||||
LucideBot,
|
LucideBot,
|
||||||
|
@ -12,30 +11,28 @@ import {
|
||||||
LucideUser,
|
LucideUser,
|
||||||
LucideX,
|
LucideX,
|
||||||
} from 'lucide-solid'
|
} from 'lucide-solid'
|
||||||
|
import { workerInvoke } from 'mtcute-repl-worker/client'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from 'solid-js'
|
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from 'solid-js'
|
||||||
import { Badge } from '../../lib/components/ui/badge.tsx'
|
import { Badge } from '../../lib/components/ui/badge.tsx'
|
||||||
|
|
||||||
import { Button } from '../../lib/components/ui/button.tsx'
|
import { Button } from '../../lib/components/ui/button.tsx'
|
||||||
import { Dialog, DialogContent } from '../../lib/components/ui/dialog.tsx'
|
import { Dialog, DialogContent } from '../../lib/components/ui/dialog.tsx'
|
||||||
|
|
||||||
import { TextField, TextFieldFrame, TextFieldRoot } from '../../lib/components/ui/text-field.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 { cn } from '../../lib/utils.ts'
|
||||||
import { $accounts, $activeAccountId } from '../../store/accounts.ts'
|
import { $accounts, $activeAccountId } from '../../store/accounts.ts'
|
||||||
import { useStore } from '../../store/use-store.ts'
|
import { useStore } from '../../store/use-store.ts'
|
||||||
import { AccountAvatar } from '../AccountAvatar.tsx'
|
import { AccountAvatar } from '../AccountAvatar.tsx'
|
||||||
import { LoginForm } from '../login/Login.tsx'
|
|
||||||
import { ImportDropdown } from './import/ImportDropdown.tsx'
|
import { ImportDropdown } from './import/ImportDropdown.tsx'
|
||||||
|
import { LoginForm } from './login/Login.tsx'
|
||||||
|
|
||||||
function AddAccountDialog(props: {
|
function AddAccountDialog(props: {
|
||||||
show: boolean
|
show: boolean
|
||||||
testMode: boolean
|
testMode: boolean
|
||||||
onClose: () => void
|
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)
|
const [accountId, setAccountId] = createSignal<string | undefined>(undefined)
|
||||||
|
|
||||||
let accountId: string
|
|
||||||
let closeTimeout: timers.Timer | undefined
|
let closeTimeout: timers.Timer | undefined
|
||||||
let finished = false
|
let finished = false
|
||||||
|
|
||||||
|
@ -44,39 +41,47 @@ function AddAccountDialog(props: {
|
||||||
finished = false
|
finished = false
|
||||||
} else {
|
} else {
|
||||||
props.onClose()
|
props.onClose()
|
||||||
client()?.close()
|
// client()?.close()
|
||||||
timers.clearTimeout(closeTimeout)
|
timers.clearTimeout(closeTimeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStepChange(step: LoginStep, ctx: Partial<StepContext>) {
|
async function handleStepChange(step: LoginStep) {
|
||||||
if (step === 'done') {
|
if (step === 'done') {
|
||||||
finished = true
|
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(() => {
|
closeTimeout = timers.setTimeout(() => {
|
||||||
props.onClose()
|
props.onClose()
|
||||||
client_.close()
|
workerInvoke('telegram', 'disposeClient', { accountId: accountId()! })
|
||||||
}, 2500)
|
}, 2500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(on(() => props.show, async (show) => {
|
createEffect(on(() => props.show, async (show) => {
|
||||||
if (!show) {
|
if (!show) {
|
||||||
if (!finished && accountId) {
|
if (!finished && accountId()) {
|
||||||
await client()?.close()
|
await workerInvoke('telegram', 'disposeClient', {
|
||||||
await deleteAccount(accountId)
|
accountId: accountId()!,
|
||||||
|
forget: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
accountId = nanoid()
|
finished = false
|
||||||
setClient(createInternalClient(accountId, props.testMode))
|
setAccountId(nanoid())
|
||||||
|
await workerInvoke('telegram', 'createClient', {
|
||||||
|
accountId: accountId()!,
|
||||||
|
testMode: props.testMode,
|
||||||
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
timers.clearTimeout(closeTimeout)
|
timers.clearTimeout(closeTimeout)
|
||||||
client()?.close()
|
if (accountId()) {
|
||||||
|
workerInvoke('telegram', 'disposeClient', {
|
||||||
|
accountId: accountId()!,
|
||||||
|
forget: !finished,
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -91,12 +96,12 @@ function AddAccountDialog(props: {
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<div class="flex h-[420px] flex-col justify-center">
|
<div class="flex h-[420px] flex-col justify-center">
|
||||||
{client() && (
|
<Show when={accountId()}>
|
||||||
<LoginForm
|
<LoginForm
|
||||||
client={client()!}
|
accountId={accountId()!}
|
||||||
onStepChange={handleStepChange}
|
onStepChange={handleStepChange}
|
||||||
/>
|
/>
|
||||||
)}
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
@ -162,11 +167,11 @@ function AccountRow(props: {
|
||||||
<LucideLogIn class="size-4" />
|
<LucideLogIn class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghostDestructive"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="size-8 hover:bg-error"
|
class="size-8"
|
||||||
>
|
>
|
||||||
<LucideTrash class="size-4 text-error-foreground" />
|
<LucideTrash class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -186,21 +191,6 @@ export function AccountsTab() {
|
||||||
setAddAccountTestMode(e.ctrlKey || e.metaKey)
|
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 filteredAccounts = createMemo(() => {
|
||||||
const query = searchQuery().toLowerCase().trim()
|
const query = searchQuery().toLowerCase().trim()
|
||||||
if (query === '') {
|
if (query === '') {
|
||||||
|
@ -219,6 +209,7 @@ export function AccountsTab() {
|
||||||
fallback={(
|
fallback={(
|
||||||
<div class="flex h-full flex-col items-center justify-center gap-4 text-muted-foreground">
|
<div class="flex h-full flex-col items-center justify-center gap-4 text-muted-foreground">
|
||||||
No accounts yet
|
No accounts yet
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
@ -227,6 +218,8 @@ export function AccountsTab() {
|
||||||
<LucidePlus class="mr-2 size-4" />
|
<LucidePlus class="mr-2 size-4" />
|
||||||
Log in
|
Log in
|
||||||
</Button>
|
</Button>
|
||||||
|
<ImportDropdown size="sm" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
@ -260,7 +253,7 @@ export function AccountsTab() {
|
||||||
Log in
|
Log in
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<ImportDropdown />
|
<ImportDropdown size="xs" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex max-w-full flex-col gap-1 overflow-hidden">
|
<div class="flex max-w-full flex-col gap-1 overflow-hidden">
|
||||||
<For each={filteredAccounts()}>
|
<For each={filteredAccounts()}>
|
||||||
|
@ -280,7 +273,6 @@ export function AccountsTab() {
|
||||||
show={showAddAccount()}
|
show={showAddAccount()}
|
||||||
testMode={addAccountTestMode()}
|
testMode={addAccountTestMode()}
|
||||||
onClose={() => setShowAddAccount(false)}
|
onClose={() => setShowAddAccount(false)}
|
||||||
onAccountCreated={handleAccountCreated}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
|
@ -1,12 +1,9 @@
|
||||||
import type { InputStringSessionData } from '@mtcute/web/utils.js'
|
|
||||||
import { hex } from '@fuman/utils'
|
import { hex } from '@fuman/utils'
|
||||||
import { DC_MAPPING_PROD, DC_MAPPING_TEST } from '@mtcute/convert'
|
|
||||||
import { createEffect, createSignal, on } from 'solid-js'
|
import { createEffect, createSignal, on } from 'solid-js'
|
||||||
import { Button } from '../../../lib/components/ui/button.tsx'
|
import { Button } from '../../../lib/components/ui/button.tsx'
|
||||||
import { Checkbox, CheckboxControl, CheckboxLabel } from '../../../lib/components/ui/checkbox.tsx'
|
import { Checkbox, CheckboxControl, CheckboxLabel } from '../../../lib/components/ui/checkbox.tsx'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.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 { 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'
|
import { $accounts } from '../../../store/accounts.ts'
|
||||||
|
|
||||||
export function AuthKeyImportDialog(props: {
|
export function AuthKeyImportDialog(props: {
|
|
@ -12,10 +12,11 @@ import {
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '../../../lib/components/ui/dropdown-menu.tsx'
|
} from '../../../lib/components/ui/dropdown-menu.tsx'
|
||||||
|
import { cn } from '../../../lib/utils.ts'
|
||||||
import { AuthKeyImportDialog } from './AuthKeyImportDialog.tsx'
|
import { AuthKeyImportDialog } from './AuthKeyImportDialog.tsx'
|
||||||
import { StringSessionDefs, StringSessionImportDialog } from './StringSessionImportDialog.tsx'
|
import { StringSessionDefs, StringSessionImportDialog } from './StringSessionImportDialog.tsx'
|
||||||
|
|
||||||
export function ImportDropdown() {
|
export function ImportDropdown(props: { size: 'xs' | 'sm' }) {
|
||||||
const [showImportStringSession, setShowImportStringSession] = createSignal(false)
|
const [showImportStringSession, setShowImportStringSession] = createSignal(false)
|
||||||
const [stringSessionLibName, setStringSessionLibName] = createSignal<StringSessionLibName>('mtcute')
|
const [stringSessionLibName, setStringSessionLibName] = createSignal<StringSessionLibName>('mtcute')
|
||||||
const [showImportAuthKey, setShowImportAuthKey] = createSignal(false)
|
const [showImportAuthKey, setShowImportAuthKey] = createSignal(false)
|
||||||
|
@ -24,13 +25,21 @@ export function ImportDropdown() {
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
as={(props: DropdownMenuTriggerProps) => (
|
as={(triggerProps: DropdownMenuTriggerProps) => (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size={props.size}
|
||||||
{...props}
|
{...triggerProps}
|
||||||
>
|
>
|
||||||
<LucideDownload class="mr-2 size-3" />
|
<LucideDownload class={
|
||||||
|
cn(
|
||||||
|
{
|
||||||
|
xs: 'mr-2 size-3',
|
||||||
|
sm: 'mr-2 size-3.5',
|
||||||
|
}[props.size],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
Import
|
Import
|
||||||
</Button>
|
</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 { createEffect, createSignal, on } from 'solid-js'
|
||||||
import { Button } from '../../../lib/components/ui/button.tsx'
|
import { Button } from '../../../lib/components/ui/button.tsx'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '../../../lib/components/ui/dialog.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 { 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 { 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'
|
import { $accounts } from '../../../store/accounts.ts'
|
||||||
|
|
||||||
export type StringSessionLibName =
|
export type StringSessionLibName =
|
||||||
|
@ -26,29 +23,29 @@ export const StringSessionDefs: {
|
||||||
{ name: 'mtkruto', displayName: 'MTKruto' },
|
{ name: 'mtkruto', displayName: 'MTKruto' },
|
||||||
]
|
]
|
||||||
|
|
||||||
async function convert(libName: StringSessionLibName, session: string): Promise<StringSessionData> {
|
// async function convert(libName: StringSessionLibName, session: string): Promise<StringSessionData> {
|
||||||
switch (libName) {
|
// switch (libName) {
|
||||||
case 'mtcute': {
|
// case 'mtcute': {
|
||||||
return readStringSession(session)
|
// return readStringSession(session)
|
||||||
}
|
// }
|
||||||
case 'telethon': {
|
// case 'telethon': {
|
||||||
const { convertFromTelethonSession } = await import('@mtcute/convert')
|
// const { convertFromTelethonSession } = await import('@mtcute/convert')
|
||||||
return convertFromTelethonSession(session)
|
// return convertFromTelethonSession(session)
|
||||||
}
|
// }
|
||||||
case 'gramjs': {
|
// case 'gramjs': {
|
||||||
const { convertFromGramjsSession } = await import('@mtcute/convert')
|
// const { convertFromGramjsSession } = await import('@mtcute/convert')
|
||||||
return convertFromGramjsSession(session)
|
// return convertFromGramjsSession(session)
|
||||||
}
|
// }
|
||||||
case 'pyrogram': {
|
// case 'pyrogram': {
|
||||||
const { convertFromPyrogramSession } = await import('@mtcute/convert')
|
// const { convertFromPyrogramSession } = await import('@mtcute/convert')
|
||||||
return convertFromPyrogramSession(session)
|
// return convertFromPyrogramSession(session)
|
||||||
}
|
// }
|
||||||
case 'mtkruto': {
|
// case 'mtkruto': {
|
||||||
const { convertFromMtkrutoSession } = await import('@mtcute/convert')
|
// const { convertFromMtkrutoSession } = await import('@mtcute/convert')
|
||||||
return convertFromMtkrutoSession(session)
|
// return convertFromMtkrutoSession(session)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function StringSessionImportDialog(props: {
|
export function StringSessionImportDialog(props: {
|
||||||
open: boolean
|
open: boolean
|
|
@ -1,17 +1,15 @@
|
||||||
import type { BaseTelegramClient, SentCode, User } from '@mtcute/web'
|
import type { mtcute, TelegramAccount } from 'mtcute-repl-worker/client'
|
||||||
import { base64 } from '@fuman/utils'
|
import { unknownToError } from '@fuman/utils'
|
||||||
import { tl } from '@mtcute/web'
|
|
||||||
import { checkPassword, downloadAsBuffer, resendCode, sendCode, signIn, signInQr } from '@mtcute/web/methods.js'
|
|
||||||
import { LucideChevronRight } from 'lucide-solid'
|
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 { createEffect, createSignal, For, Match, onCleanup, onMount, Show, Switch } from 'solid-js'
|
||||||
import { renderSVG } from 'uqr'
|
import { Button } from '../../../lib/components/ui/button.tsx'
|
||||||
import { Avatar, AvatarFallback, AvatarImage, makeAvatarFallbackText } from '../../lib/components/ui/avatar.tsx'
|
import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSlot } from '../../../lib/components/ui/otp-field.tsx'
|
||||||
import { Button } from '../../lib/components/ui/button.tsx'
|
import { Spinner } from '../../../lib/components/ui/spinner.tsx'
|
||||||
import { OTPField, OTPFieldGroup, OTPFieldInput, OTPFieldSlot } from '../../lib/components/ui/otp-field.tsx'
|
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../../lib/components/ui/text-field.tsx'
|
||||||
import { Spinner } from '../../lib/components/ui/spinner.tsx'
|
import { TransitionSlideLtr } from '../../../lib/components/ui/transition.tsx'
|
||||||
import { TextField, TextFieldErrorMessage, TextFieldFrame, TextFieldLabel, TextFieldRoot } from '../../lib/components/ui/text-field.tsx'
|
import { cn } from '../../../lib/utils.ts'
|
||||||
import { TransitionSlideLtr } from '../../lib/components/ui/transition.tsx'
|
import { AccountAvatar } from '../../AccountAvatar.tsx'
|
||||||
import { cn } from '../../lib/utils.ts'
|
|
||||||
import { PhoneInput } from './PhoneInput.tsx'
|
import { PhoneInput } from './PhoneInput.tsx'
|
||||||
|
|
||||||
export type LoginStep =
|
export type LoginStep =
|
||||||
|
@ -25,14 +23,14 @@ export interface StepContext {
|
||||||
phone: void
|
phone: void
|
||||||
otp: {
|
otp: {
|
||||||
phone: string
|
phone: string
|
||||||
code: SentCode
|
code: mtcute.SentCode
|
||||||
}
|
}
|
||||||
password: void
|
password: void
|
||||||
done: { user: User }
|
done: { account: TelegramAccount }
|
||||||
}
|
}
|
||||||
|
|
||||||
type StepProps<T extends LoginStep> = {
|
type StepProps<T extends LoginStep> = {
|
||||||
client: BaseTelegramClient
|
accountId: string
|
||||||
setStep: <T extends LoginStep>(step: T, data?: StepContext[T]) => void
|
setStep: <T extends LoginStep>(step: T, data?: StepContext[T]) => void
|
||||||
} & (StepContext[T] extends void ? {} : { ctx: StepContext[T] })
|
} & (StepContext[T] extends void ? {} : { ctx: StepContext[T] })
|
||||||
|
|
||||||
|
@ -41,23 +39,29 @@ function QrLoginStep(props: StepProps<'qr'>) {
|
||||||
const [finalizing, setFinalizing] = createSignal(false)
|
const [finalizing, setFinalizing] = createSignal(false)
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
signInQr(props.client, {
|
const cleanup1 = workerOn('QrCodeUpdate', (e) => {
|
||||||
abortSignal: abortController.signal,
|
if (e.accountId !== props.accountId) return
|
||||||
onUrlUpdated: qr => setQr(renderSVG(qr)),
|
setQr(e.qrCode)
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
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())
|
onCleanup(() => abortController.abort())
|
||||||
|
|
||||||
|
@ -104,26 +108,29 @@ function PhoneNumberStep(props: StepProps<'phone'>) {
|
||||||
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
|
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
|
||||||
|
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
const handleSubmit = () => {
|
const handleSubmit = async () => {
|
||||||
setError(undefined)
|
setError(undefined)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
sendCode(props.client, {
|
|
||||||
|
try {
|
||||||
|
const code = await workerInvoke('telegram', 'sendCode', {
|
||||||
|
accountId: props.accountId,
|
||||||
phone: phone(),
|
phone: phone(),
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
}).then((code) => {
|
})
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
props.setStep('otp', {
|
props.setStep('otp', {
|
||||||
code,
|
code,
|
||||||
phone: phone(),
|
phone: phone(),
|
||||||
})
|
})
|
||||||
}).catch((e) => {
|
} catch (e) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
// ignore
|
// ignore
|
||||||
} else {
|
} else {
|
||||||
setError(e.message)
|
setError(unknownToError(e).message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
onCleanup(() => abortController.abort())
|
onCleanup(() => abortController.abort())
|
||||||
createEffect(() => inputRef()?.focus())
|
createEffect(() => inputRef()?.focus())
|
||||||
|
@ -150,7 +157,7 @@ function PhoneNumberStep(props: StepProps<'phone'>) {
|
||||||
<div class="flex flex-row">
|
<div class="flex flex-row">
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
class="w-[300px]"
|
class="w-[300px]"
|
||||||
client={props.client}
|
accountId={props.accountId}
|
||||||
onChange={setPhone}
|
onChange={setPhone}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
|
@ -192,50 +199,57 @@ function OtpStep(props: StepProps<'otp'>) {
|
||||||
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
|
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
|
||||||
|
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
const handleSubmit = () => {
|
const handleSubmit = async () => {
|
||||||
setError(undefined)
|
setError(undefined)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
signIn(props.client, {
|
|
||||||
|
try {
|
||||||
|
const account = await workerInvoke('telegram', 'signIn', {
|
||||||
|
accountId: props.accountId,
|
||||||
phone: props.ctx.phone,
|
phone: props.ctx.phone,
|
||||||
phoneCodeHash: props.ctx.code.phoneCodeHash,
|
phoneCodeHash: props.ctx.code.phoneCodeHash,
|
||||||
phoneCode: otp(),
|
phoneCode: otp(),
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
}).then((user) => {
|
})
|
||||||
setLoading(false)
|
if (account === 'need_password') {
|
||||||
props.setStep('done', { user })
|
props.setStep('password')
|
||||||
}).catch((e) => {
|
} else {
|
||||||
|
props.setStep('done', { account })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
// ignore
|
// ignore
|
||||||
} else if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED')) {
|
|
||||||
props.setStep('password')
|
|
||||||
} else {
|
} else {
|
||||||
setError(e.message)
|
setError(unknownToError(e).message)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
const handleResend = () => {
|
}
|
||||||
|
const handleResend = async () => {
|
||||||
setError(undefined)
|
setError(undefined)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
resendCode(props.client, {
|
try {
|
||||||
|
const code = await workerInvoke('telegram', 'resendCode', {
|
||||||
|
accountId: props.accountId,
|
||||||
phone: props.ctx.phone,
|
phone: props.ctx.phone,
|
||||||
phoneCodeHash: props.ctx.code.phoneCodeHash,
|
phoneCodeHash: props.ctx.code.phoneCodeHash,
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
}).then((code) => {
|
})
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
props.setStep('otp', {
|
props.setStep('otp', {
|
||||||
code,
|
code,
|
||||||
phone: props.ctx.phone,
|
phone: props.ctx.phone,
|
||||||
})
|
})
|
||||||
}).catch((e) => {
|
} catch (e) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
// ignore
|
// ignore
|
||||||
} else {
|
} else {
|
||||||
setError(e.message)
|
setError(unknownToError(e).message)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSetOtp = (otp: string) => {
|
const handleSetOtp = (otp: string) => {
|
||||||
setOtp(otp)
|
setOtp(otp)
|
||||||
if (otp.length === props.ctx.code.length) {
|
if (otp.length === props.ctx.code.length) {
|
||||||
|
@ -385,8 +399,10 @@ function PasswordStep(props: StepProps<'password'>) {
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
|
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
|
||||||
|
|
||||||
// todo abort controller
|
const abortController = new AbortController()
|
||||||
const handleSubmit = () => {
|
onCleanup(() => abortController.abort())
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
if (!password()) {
|
if (!password()) {
|
||||||
setError('Password is required')
|
setError('Password is required')
|
||||||
return
|
return
|
||||||
|
@ -394,19 +410,17 @@ function PasswordStep(props: StepProps<'password'>) {
|
||||||
|
|
||||||
setError(undefined)
|
setError(undefined)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
checkPassword(props.client, password())
|
try {
|
||||||
.then((user) => {
|
const user = await workerInvoke('telegram', 'checkPassword', {
|
||||||
setLoading(false)
|
accountId: props.accountId,
|
||||||
props.setStep('done', { user })
|
password: password(),
|
||||||
|
abortSignal: abortController.signal,
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
props.setStep('done', { account: user })
|
||||||
|
} catch (e) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (tl.RpcError.is(e, 'PASSWORD_HASH_INVALID')) {
|
setError(unknownToError(e).message)
|
||||||
setError('Incorrect password')
|
|
||||||
} else {
|
|
||||||
setError(e.message)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
createEffect(() => inputRef()?.focus())
|
createEffect(() => inputRef()?.focus())
|
||||||
|
|
||||||
|
@ -456,47 +470,16 @@ function PasswordStep(props: StepProps<'password'>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function DoneStep(props: StepProps<'done'>) {
|
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 (
|
return (
|
||||||
<div class="flex flex-col items-center justify-center">
|
<div class="flex flex-col items-center justify-center">
|
||||||
<Avatar class="mb-4 size-24 shadow-sm">
|
<AccountAvatar
|
||||||
{props.ctx.user.photo && (
|
account={props.ctx.account}
|
||||||
<>
|
class="mb-4 size-24 shadow-sm"
|
||||||
{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>
|
|
||||||
<div class="text-center font-medium">
|
<div class="text-center font-medium">
|
||||||
Welcome,
|
Welcome,
|
||||||
{' '}
|
{' '}
|
||||||
{props.ctx.user.displayName}
|
{props.ctx.account.name}
|
||||||
!
|
!
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -505,7 +488,7 @@ function DoneStep(props: StepProps<'done'>) {
|
||||||
|
|
||||||
export function LoginForm(props: {
|
export function LoginForm(props: {
|
||||||
class?: string
|
class?: string
|
||||||
client: BaseTelegramClient
|
accountId: string
|
||||||
onStepChange?: (step: LoginStep, ctx: Partial<StepContext>) => void
|
onStepChange?: (step: LoginStep, ctx: Partial<StepContext>) => void
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = createSignal<LoginStep>('qr')
|
const [step, setStep] = createSignal<LoginStep>('qr')
|
||||||
|
@ -522,20 +505,20 @@ export function LoginForm(props: {
|
||||||
<TransitionSlideLtr mode="outin">
|
<TransitionSlideLtr mode="outin">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={step() === 'qr'}>
|
<Match when={step() === 'qr'}>
|
||||||
<QrLoginStep client={props.client} setStep={setStepWithCtx} />
|
<QrLoginStep accountId={props.accountId} setStep={setStepWithCtx} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={step() === 'phone'}>
|
<Match when={step() === 'phone'}>
|
||||||
<PhoneNumberStep client={props.client} setStep={setStepWithCtx} />
|
<PhoneNumberStep accountId={props.accountId} setStep={setStepWithCtx} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={step() === 'otp'}>
|
<Match when={step() === 'otp'}>
|
||||||
<OtpStep client={props.client} setStep={setStepWithCtx} ctx={ctx().otp!} />
|
<OtpStep accountId={props.accountId} setStep={setStepWithCtx} ctx={ctx().otp!} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={step() === 'password'}>
|
<Match when={step() === 'password'}>
|
||||||
<PasswordStep client={props.client} setStep={setStepWithCtx} />
|
<PasswordStep accountId={props.accountId} setStep={setStepWithCtx} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={step() === 'done'}>
|
<Match when={step() === 'done'}>
|
||||||
<DoneStep
|
<DoneStep
|
||||||
client={props.client}
|
accountId={props.accountId}
|
||||||
setStep={setStepWithCtx}
|
setStep={setStepWithCtx}
|
||||||
ctx={ctx().done!}
|
ctx={ctx().done!}
|
||||||
/>
|
/>
|
|
@ -1,10 +1,8 @@
|
||||||
import type { BaseTelegramClient, tl } from '@mtcute/web'
|
import { type mtcute, workerInvoke } from 'mtcute-repl-worker/client'
|
||||||
|
|
||||||
import { assert } from '@fuman/utils'
|
|
||||||
import { createSignal, onMount, Show } from 'solid-js'
|
import { createSignal, onMount, Show } from 'solid-js'
|
||||||
import { CountryIcon } from '../../lib/components/country-icon.tsx'
|
import { CountryIcon } from '../../../lib/components/country-icon.tsx'
|
||||||
import { TextField, TextFieldFrame } from '../../lib/components/ui/text-field.tsx'
|
import { TextField, TextFieldFrame } from '../../../lib/components/ui/text-field.tsx'
|
||||||
import { cn } from '../../lib/utils.ts'
|
import { cn } from '../../../lib/utils.ts'
|
||||||
|
|
||||||
interface ChosenCode {
|
interface ChosenCode {
|
||||||
patterns?: string[]
|
patterns?: string[]
|
||||||
|
@ -12,7 +10,7 @@ interface ChosenCode {
|
||||||
iso2: string
|
iso2: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapCountryCode(country: tl.help.RawCountry, code: tl.help.RawCountryCode): ChosenCode {
|
function mapCountryCode(country: mtcute.RawCountry, code: mtcute.RawCountryCode): ChosenCode {
|
||||||
return {
|
return {
|
||||||
patterns: code.patterns,
|
patterns: code.patterns,
|
||||||
countryCode: code.countryCode,
|
countryCode: code.countryCode,
|
||||||
|
@ -25,28 +23,24 @@ interface PhoneInputProps {
|
||||||
phone?: string
|
phone?: string
|
||||||
onChange?: (phone: string) => void
|
onChange?: (phone: string) => void
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
client: BaseTelegramClient
|
accountId: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
ref?: (el: HTMLInputElement) => void
|
ref?: (el: HTMLInputElement) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhoneInput(props: PhoneInputProps) {
|
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 [chosenCode, setChosenCode] = createSignal<ChosenCode | undefined>()
|
||||||
const [inputValue, setInputValue] = createSignal('+')
|
const [inputValue, setInputValue] = createSignal('+')
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
Promise.all([
|
const { countries, countryByIp } = await workerInvoke('telegram', 'loadCountries', { accountId: props.accountId })
|
||||||
props.client.call({ _: 'help.getCountriesList', langCode: 'en', hash: 0 }),
|
setCountriesList(countries)
|
||||||
props.client.call({ _: 'help.getNearestDc' }),
|
|
||||||
]).then(([countriesList, nearestDc]) => {
|
|
||||||
assert(countriesList._ === 'help.countriesList') // todo caching
|
|
||||||
setCountriesList(countriesList.countries)
|
|
||||||
|
|
||||||
if (inputValue() === '+') {
|
if (inputValue() === '+') {
|
||||||
// guess the country code
|
// guess the country code
|
||||||
for (const country of countriesList.countries) {
|
for (const country of countries) {
|
||||||
if (country.iso2 === nearestDc.country.toUpperCase()) {
|
if (country.iso2 === countryByIp) {
|
||||||
setChosenCode(mapCountryCode(country, country.countryCodes[0]))
|
setChosenCode(mapCountryCode(country, country.countryCodes[0]))
|
||||||
setInputValue(`+${country.countryCodes[0].countryCode} `)
|
setInputValue(`+${country.countryCodes[0].countryCode} `)
|
||||||
break
|
break
|
||||||
|
@ -54,7 +48,6 @@ export function PhoneInput(props: PhoneInputProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
const handleInput = (e: InputEvent) => {
|
const handleInput = (e: InputEvent) => {
|
||||||
const el = e.currentTarget as HTMLInputElement
|
const el = e.currentTarget as HTMLInputElement
|
||||||
|
@ -77,7 +70,7 @@ export function PhoneInput(props: PhoneInputProps) {
|
||||||
el.value = `+${value.replace(/[^\d ]/g, '')}`
|
el.value = `+${value.replace(/[^\d ]/g, '')}`
|
||||||
|
|
||||||
// pass 1: find matching countries by country code
|
// 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
|
let hasPrefixes = false
|
||||||
|
|
||||||
for (const country of countriesList()) {
|
for (const country of countriesList()) {
|
|
@ -2,11 +2,10 @@
|
||||||
import { render } from 'solid-js/web'
|
import { render } from 'solid-js/web'
|
||||||
|
|
||||||
import { App } from './App'
|
import { App } from './App'
|
||||||
import { registerServiceWorker } from './sw/register.ts'
|
|
||||||
import './app.css'
|
import './app.css'
|
||||||
|
|
||||||
const root = document.getElementById('root')
|
const root = document.getElementById('root')
|
||||||
|
|
||||||
registerServiceWorker()
|
// registerServiceWorker()
|
||||||
|
|
||||||
render(() => <App />, root!)
|
render(() => <App />, root!)
|
|
@ -17,6 +17,7 @@ const buttonVariants = cva(
|
||||||
outline: 'border border-input text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
outline: 'border border-input text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
ghostDestructive: 'text-error-foreground hover:bg-error',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
},
|
},
|
||||||
size: {
|
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 * as v from '@badrap/valita'
|
||||||
import { persistentAtom } from '@nanostores/persistent'
|
import { persistentAtom } from '@nanostores/persistent'
|
||||||
import { computed } from 'nanostores'
|
import { linkAtom } from './link.ts'
|
||||||
|
|
||||||
export interface TelegramAccount {
|
export interface TelegramAccount {
|
||||||
id: string
|
id: string
|
||||||
|
@ -37,13 +37,7 @@ export const $accounts = persistentAtom<TelegramAccount[]>('repl:accounts', [],
|
||||||
return res
|
return res
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
linkAtom($accounts, 'accounts')
|
||||||
|
|
||||||
export const $activeAccountId = persistentAtom<string>('repl:activeAccountId')
|
export const $activeAccountId = persistentAtom<string>('repl:activeAccountId')
|
||||||
export const $activeAccount = computed([$accounts, $activeAccountId], (accounts, activeAccountId) => {
|
linkAtom($activeAccountId, 'activeAccountId')
|
||||||
if (!activeAccountId) return null
|
|
||||||
|
|
||||||
const account = accounts.find(account => account.id === activeAccountId)
|
|
||||||
if (!account) return null
|
|
||||||
|
|
||||||
return account
|
|
||||||
})
|
|
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 type { BaseTelegramClient } from '@mtcute/web'
|
||||||
import { downloadAsBuffer, getMe } from '@mtcute/web/methods.js'
|
import { downloadAsBuffer, getMe } from '@mtcute/web/methods.js'
|
||||||
import { createInternalClient } from '../lib/telegram.ts'
|
import { createInternalClient } from '../utils/telegram.ts'
|
||||||
import { timeout } from '../lib/utils.ts'
|
import { timeout } from '../utils/timeout.ts'
|
||||||
import { $accounts } from '../store/accounts.ts'
|
|
||||||
import { getCacheStorage } from './cache.ts'
|
import { getCacheStorage } from './cache.ts'
|
||||||
|
|
||||||
const clients = new Map<string, BaseTelegramClient>()
|
const clients = new Map<string, BaseTelegramClient>()
|
||||||
|
@ -38,8 +37,10 @@ export async function handleAvatarRequest(accountId: string) {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'image/jpeg',
|
'Content-Type': 'image/jpeg',
|
||||||
'Cache-Control': 'public, max-age=86400',
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await cache.put(cacheKey, res.clone())
|
await cache.put(cacheKey, res.clone())
|
||||||
|
|
||||||
return res
|
return res
|
|
@ -1,4 +1,4 @@
|
||||||
import { timeout } from '../lib/utils.ts'
|
import { timeout } from '../utils/timeout.ts'
|
||||||
|
|
||||||
let _cacheStorage: Cache | undefined
|
let _cacheStorage: Cache | undefined
|
||||||
|
|
||||||
|
@ -14,7 +14,6 @@ export async function getCacheStorage() {
|
||||||
|
|
||||||
export async function requestCache(event: FetchEvent) {
|
export async function requestCache(event: FetchEvent) {
|
||||||
try {
|
try {
|
||||||
// const cache = await ctx.caches.open(CACHE_ASSETS_NAME);
|
|
||||||
const cache = await timeout(getCacheStorage(), 10000)
|
const cache = await timeout(getCacheStorage(), 10000)
|
||||||
const cachedRes = await timeout(cache.match(event.request), 10000)
|
const cachedRes = await timeout(cache.match(event.request), 10000)
|
||||||
|
|
|
@ -9,7 +9,7 @@ let registered = false
|
||||||
let nextId = 0
|
let nextId = 0
|
||||||
const pending = new Map<number, Deferred<any>>()
|
const pending = new Map<number, Deferred<any>>()
|
||||||
|
|
||||||
function swInvokeMethod(request: SwMessage) {
|
export function swInvokeMethod(request: SwMessage) {
|
||||||
const sw = getServiceWorker()
|
const sw = getServiceWorker()
|
||||||
if (!registered) {
|
if (!registered) {
|
||||||
navigator.serviceWorker.addEventListener('message', (e) => {
|
navigator.serviceWorker.addEventListener('message', (e) => {
|
||||||
|
@ -33,15 +33,3 @@ function swInvokeMethod(request: SwMessage) {
|
||||||
sw.postMessage(request)
|
sw.postMessage(request)
|
||||||
return def.promise
|
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' {
|
declare module '@mtcute/web?external' {
|
||||||
export * from '@mtcute/web'
|
export * from '@mtcute/web'
|
||||||
}
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
// eslint-disable-next-line antfu/no-import-dist
|
// eslint-disable-next-line antfu/no-import-dist
|
||||||
import chobitsuUrl from '../../vendor/chobitsu/dist/chobitsu.js?url'
|
import chobitsuUrl from '../../../../../vendor/chobitsu/dist/chobitsu.js?url'
|
||||||
import runnerScriptUrl from '../components/runner/iframe.ts?url'
|
import runnerScriptUrl from './script.ts?url'
|
||||||
|
|
||||||
export async function generateImportMap(packageJsons: any[]) {
|
export async function generateImportMap(packageJsons: any[]) {
|
||||||
const importMap: Record<string, string> = {}
|
const importMap: Record<string, string> = {}
|
|
@ -3,6 +3,8 @@ import { TelegramClient } from '@mtcute/web?external'
|
||||||
type ConnectionState = import('@mtcute/web').ConnectionState
|
type ConnectionState = import('@mtcute/web').ConnectionState
|
||||||
type TelegramClientOptions = import('@mtcute/web').TelegramClientOptions
|
type TelegramClientOptions = import('@mtcute/web').TelegramClientOptions
|
||||||
|
|
||||||
|
const HOST_ORIGIN = import.meta.env.VITE_HOST_ORIGIN
|
||||||
|
|
||||||
declare const chobitsu: any
|
declare const chobitsu: any
|
||||||
|
|
||||||
declare const window: typeof globalThis & {
|
declare const window: typeof globalThis & {
|
||||||
|
@ -12,7 +14,7 @@ declare const window: typeof globalThis & {
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendToDevtools(message: any) {
|
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) {
|
function sendToChobitsu(message: any) {
|
||||||
|
@ -37,6 +39,7 @@ function initClient(accountId: string) {
|
||||||
if (storedAccounts) {
|
if (storedAccounts) {
|
||||||
const accounts = JSON.parse(storedAccounts)
|
const accounts = JSON.parse(storedAccounts)
|
||||||
const ourAccount = accounts.find((it: any) => it.id === accountId)
|
const ourAccount = accounts.find((it: any) => it.id === accountId)
|
||||||
|
if (!ourAccount) return
|
||||||
|
|
||||||
if (ourAccount && ourAccount.testMode) {
|
if (ourAccount && ourAccount.testMode) {
|
||||||
extraConfig = {
|
extraConfig = {
|
||||||
|
@ -53,7 +56,7 @@ function initClient(accountId: string) {
|
||||||
})
|
})
|
||||||
window.tg.onConnectionState.add((state) => {
|
window.tg.onConnectionState.add((state) => {
|
||||||
lastConnectionState = 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' })
|
sendToDevtools({ method: 'DOM.documentUpdated' })
|
||||||
|
|
||||||
initClient(data.accountId)
|
initClient(data.accountId)
|
||||||
window.tg.connect()
|
window.tg?.connect()
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
window.parent.postMessage({ event: 'PING' }, HOST_ORIGIN)
|
||||||
|
}, 500)
|
||||||
} else if (data.event === 'RUN') {
|
} else if (data.event === 'RUN') {
|
||||||
const el = document.createElement('script')
|
const el = document.createElement('script')
|
||||||
el.type = 'module'
|
el.type = 'module'
|
||||||
|
@ -103,7 +110,7 @@ window.addEventListener('message', ({ data }) => {
|
||||||
initClient(data.accountId)
|
initClient(data.accountId)
|
||||||
|
|
||||||
if (lastConnectionState !== 'offline') {
|
if (lastConnectionState !== 'offline') {
|
||||||
window.parent.postMessage({ event: 'CONNECTION_STATE', value: 'offline' }, '*')
|
window.parent.postMessage({ event: 'CONNECTION_STATE', value: 'offline' }, HOST_ORIGIN)
|
||||||
window.tg.connect()
|
window.tg.connect()
|
||||||
}
|
}
|
||||||
} else if (data.event === 'DISCONNECT') {
|
} else if (data.event === 'DISCONNECT') {
|
||||||
|
@ -112,7 +119,7 @@ window.addEventListener('message', ({ data }) => {
|
||||||
if (lastAccountId) {
|
if (lastAccountId) {
|
||||||
initClient(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') {
|
} else if (data.event === 'RECONNECT') {
|
||||||
window.tg.connect()
|
window.tg.connect()
|
||||||
}
|
}
|
||||||
|
@ -120,7 +127,7 @@ window.addEventListener('message', ({ data }) => {
|
||||||
|
|
||||||
window.__handleScriptEnd = (error) => {
|
window.__handleScriptEnd = (error) => {
|
||||||
if (!window.__currentScript) return
|
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.remove()
|
||||||
window.__currentScript = undefined
|
window.__currentScript = undefined
|
||||||
}
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { unknownToError } from '@fuman/utils'
|
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 { handleAvatarRequest } from './avatar.ts'
|
||||||
import { requestCache } from './cache.ts'
|
import { requestCache } from './cache.ts'
|
||||||
import { clearCache, forgetScript, handleRuntimeRequest, uploadScript } from './runtime.ts'
|
import { clearCache, forgetScript, handleRuntimeRequest, uploadScript } from './runtime.ts'
|
|
@ -1,6 +1,6 @@
|
||||||
import { utf8 } from '@fuman/utils'
|
import { utf8 } from '@fuman/utils'
|
||||||
import { generateImportMap, generateRunnerHtml } from '../lib/runtime.ts'
|
import { VfsStorage } from '../vfs/storage.ts'
|
||||||
import { VfsStorage } from '../lib/vfs/storage.ts'
|
import { generateImportMap, generateRunnerHtml } from './iframe/html.ts'
|
||||||
|
|
||||||
const libraryCache = new Map<string, Map<string, Uint8Array>>()
|
const libraryCache = new Map<string, Map<string, Uint8Array>>()
|
||||||
let importMapCache: Record<string, string> | undefined
|
let importMapCache: Record<string, string> | undefined
|
||||||
|
@ -59,9 +59,6 @@ export async function handleRuntimeRequest(url: URL) {
|
||||||
const path = url.pathname.slice('/sw/runtime/'.length)
|
const path = url.pathname.slice('/sw/runtime/'.length)
|
||||||
|
|
||||||
if (path === '_iframe.html') {
|
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) {
|
if (!importMapCache) {
|
||||||
const vfs = await getVfs()
|
const vfs = await getVfs()
|
||||||
const libNames = (await vfs.getAvailableLibs()).filter(lib => !lib.startsWith('@types/'))
|
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> {
|
export function timeout<T>(promise: Promise<T>, timeout: number): Promise<T> {
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
|
@ -1,7 +1,7 @@
|
||||||
import type { VfsFile, VfsStorage } from './storage'
|
import type { VfsFile, VfsStorage } from './storage'
|
||||||
import { read, webReadableToFuman } from '@fuman/io'
|
import { read, webReadableToFuman } from '@fuman/io'
|
||||||
import { asyncPool, AsyncQueue, utf8 } from '@fuman/utils'
|
import { asyncPool, AsyncQueue, utf8 } from '@fuman/utils'
|
||||||
import { ffetch } from '../ffetch.ts'
|
import { ffetch } from '../utils/ffetch.ts'
|
||||||
import { GunzipStream } from './gzip.ts'
|
import { GunzipStream } from './gzip.ts'
|
||||||
import { extractTar, type TarEntry } from './tar.ts'
|
import { extractTar, type TarEntry } from './tar.ts'
|
||||||
|
|
||||||
|
@ -15,7 +15,9 @@ const PACKAGES_TO_SKIP = new Set([
|
||||||
])
|
])
|
||||||
|
|
||||||
export async function getLatestVersions() {
|
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)) {
|
for (const pkg of Object.keys(versions)) {
|
||||||
if (PACKAGES_TO_SKIP.has(pkg)) {
|
if (PACKAGES_TO_SKIP.has(pkg)) {
|
||||||
delete versions[pkg]
|
delete versions[pkg]
|
||||||
|
@ -148,7 +150,6 @@ export async function downloadNpmPackage(params: {
|
||||||
storage: VfsStorage
|
storage: VfsStorage
|
||||||
progress: (downloaded: number, total: number, file: string) => void
|
progress: (downloaded: number, total: number, file: string) => void
|
||||||
filterFiles?: (file: TarEntry) => boolean
|
filterFiles?: (file: TarEntry) => boolean
|
||||||
signal: AbortSignal
|
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
packageName,
|
packageName,
|
||||||
|
@ -156,12 +157,11 @@ export async function downloadNpmPackage(params: {
|
||||||
storage,
|
storage,
|
||||||
progress,
|
progress,
|
||||||
filterFiles,
|
filterFiles,
|
||||||
signal,
|
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
const tgzUrl = `https://registry.npmjs.org/${packageName}/-/${packageName.replace(/^.*?\//, '')}-${version}.tgz`
|
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) {
|
if (!response.ok || !response.body) {
|
||||||
throw new Error(`Failed to download: HTTP ${response.status}`)
|
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,
|
"isolatedModules": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": [
|
||||||
|
"packages/*/src"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,5 +18,7 @@
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"skipLibCheck": 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