├── assets ├── system ├── preview ├── .assetsignore ├── shell.html ├── img │ ├── icons │ │ ├── close.svg │ │ └── info.svg │ ├── loader.svg │ └── transparent-logomark.svg ├── _headers ├── signout.html ├── com │ ├── account.html │ ├── sponsor.html │ ├── share_embed.html │ ├── share.html │ ├── feedback.html │ ├── project.html │ ├── share_share.html │ └── share_publish.html ├── signin.html ├── _frame.html ├── favicon.svg ├── bundles.html ├── lib │ ├── dropdown.js │ ├── toast.js │ ├── html-component.js │ └── apptron.js ├── _env.html ├── _vscode.html ├── sw.js └── dashboard.html ├── extension ├── preview │ ├── package.nls.json │ ├── .gitignore │ ├── tsconfig.json │ ├── esbuild.js │ ├── package.json │ └── README.md └── system │ ├── package.nls.json │ ├── .gitignore │ ├── tsconfig.json │ ├── esbuild.js │ ├── package.json │ ├── src │ ├── web │ │ ├── extension.ts │ │ └── bridge.ts │ └── wanix │ │ └── fs.js │ └── themes │ └── Tractor-color-theme.json ├── system ├── kernel │ ├── .gitignore │ ├── Makefile │ └── Dockerfile ├── etc │ ├── resolv.conf │ ├── goprofile │ └── profile ├── bin │ ├── wexec │ ├── post-dhcp │ ├── publish │ ├── start │ ├── open │ ├── init │ └── rebuild ├── cmd │ └── aptn │ │ ├── go.mod │ │ ├── main.go │ │ ├── shm9p.go │ │ ├── go.sum │ │ ├── shmtest.go │ │ ├── fuse.go │ │ ├── ports.go │ │ └── exec.go └── apptron │ └── WELCOME.md ├── .env.example ├── worker ├── package.json ├── src │ ├── config.ts │ ├── auth.ts │ ├── public.ts │ ├── context.ts │ ├── util.ts │ ├── projects.ts │ └── worker.ts ├── tsconfig.json ├── go.mod ├── package-lock.json ├── go.sum └── cmd │ └── worker │ └── main.go ├── .gitignore ├── go.mod ├── wrangler.toml ├── .github └── workflows │ ├── deploy.yml │ └── kernel.yml ├── Makefile ├── Dockerfile ├── go.sum └── README.md /assets/system: -------------------------------------------------------------------------------- 1 | ../extension/system -------------------------------------------------------------------------------- /assets/preview: -------------------------------------------------------------------------------- 1 | ../extension/preview -------------------------------------------------------------------------------- /extension/preview/package.nls.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /extension/system/package.nls.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /system/kernel/.gitignore: -------------------------------------------------------------------------------- 1 | bzImage 2 | -------------------------------------------------------------------------------- /extension/system/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /system/etc/resolv.conf: -------------------------------------------------------------------------------- 1 | nameserver 1.1.1.1 2 | -------------------------------------------------------------------------------- /extension/preview/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /system/bin/wexec: -------------------------------------------------------------------------------- 1 | #!/bin/busybox sh 2 | exec /bin/aptn exec $@ -------------------------------------------------------------------------------- /assets/.assetsignore: -------------------------------------------------------------------------------- 1 | system/node_modules 2 | preview/node_modules 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | LOCALHOST=localhost:8788 2 | AUTH_URL=https://ad6044b5-53c2-4cb5-8542-9fdaef75f771.hanko.io -------------------------------------------------------------------------------- /worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@cloudflare/containers": "^0.0.25", 4 | "modern-tar": "^0.5.4" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /worker/src/config.ts: -------------------------------------------------------------------------------- 1 | export const HOST_DOMAIN = "apptron.dev"; 2 | export const ADMIN_USERS = ["progrium"]; 3 | export const PUBLISH_DOMAINS = ["aptn.pub"]; -------------------------------------------------------------------------------- /assets/shell.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Apptron Debug Shell 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /system/kernel/Makefile: -------------------------------------------------------------------------------- 1 | 2 | # we force linux/amd64 as a conventional default for all platforms 3 | image: 4 | docker build --platform linux/amd64 -t ghcr.io/tractordev/apptron:kernel -f Dockerfile . 5 | .PHONY: image 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.local 3 | /local 4 | .wrangler 5 | node_modules 6 | /assets/vscode 7 | /session/bundle.tgz 8 | /assets/wanix.debug.wasm 9 | /assets/wanix.wasm 10 | /assets/wanix.js 11 | kernel/bzImage 12 | .env.local 13 | -------------------------------------------------------------------------------- /system/bin/post-dhcp: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | case "$1" in 3 | bound|renew) 4 | # Configure interface with IP from DHCP 5 | ifconfig $interface $ip netmask $subnet 6 | 7 | # Set up routing 8 | route add default gw $router 9 | ;; 10 | esac 11 | -------------------------------------------------------------------------------- /system/kernel/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/progrium/linux-build:latest AS build 2 | COPY kernel.config .config 3 | RUN make ARCH=i386 CROSS_COMPILE=i686-linux-gnu- oldconfig < /dev/null && \ 4 | make ARCH=i386 CROSS_COMPILE=i686-linux-gnu- bzImage -j$(nproc) 5 | 6 | FROM scratch AS kernel 7 | COPY --from=build /build/arch/x86/boot/bzImage /bzImage 8 | CMD ["true"] -------------------------------------------------------------------------------- /system/etc/goprofile: -------------------------------------------------------------------------------- 1 | echo bundle goroot >> /ctl 2 | echo bundle gocache-${GOARCH:-386} rw >> /ctl 3 | echo bind '#goroot' go >> /ctl 4 | echo bind '#'gocache-${GOARCH:-386} go/cache >> /ctl 5 | export GOCACHE=/go/cache 6 | export GOMODCACHE=/web/idbfs/apptron/go/modcache 7 | export GOPROXY=https://proxy.golang.org,direct 8 | export PATH=/go/bin:$PATH 9 | go telemetry off -------------------------------------------------------------------------------- /assets/img/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/_headers: -------------------------------------------------------------------------------- 1 | /*.wasm 2 | Access-Control-Allow-Origin: * 3 | /*.tgz 4 | Access-Control-Allow-Origin: * 5 | /_vscode 6 | Cross-Origin-Opener-Policy: same-origin 7 | Cross-Origin-Embedder-Policy: require-corp 8 | Cross-Origin-Resource-Policy: cross-origin 9 | /vscode/* 10 | Cross-Origin-Opener-Policy: same-origin 11 | Cross-Origin-Embedder-Policy: require-corp 12 | Cross-Origin-Resource-Policy: cross-origin 13 | -------------------------------------------------------------------------------- /assets/signout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Apptron 6 | 7 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["./worker.d.ts"], 4 | "target": "ES2021", 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "lib": [ 8 | "ES2021" 9 | ], 10 | "noEmit": true, 11 | "strict": false, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true, 14 | "resolveJsonModule": true 15 | } 16 | } -------------------------------------------------------------------------------- /system/bin/publish: -------------------------------------------------------------------------------- 1 | #!/bin/busybox sh 2 | if [ -z "$1" ]; then 3 | echo "Error: No source directory provided." >&2 4 | exit 1 5 | fi 6 | 7 | src=$(realpath $1) 8 | 9 | if [ ! -d "/public" ]; then 10 | echo "Error: /public does not exist." >&2 11 | exit 1 12 | fi 13 | 14 | if [ ! -d "$src" ]; then 15 | echo "Error: $src is not a directory." >&2 16 | exit 1 17 | fi 18 | 19 | echo "sync vm/1/fsys/${src#/} vm/1/fsys/public" >> /ctl 20 | 21 | echo "Publish complete" -------------------------------------------------------------------------------- /system/bin/start: -------------------------------------------------------------------------------- 1 | #!/bin/busybox sh 2 | 3 | clear 4 | echo "Base System: $(grep "^PRETTY_NAME=" /etc/os-release | cut -d= -f2 | tr -d '"')" 5 | echo "Architecture: $(uname -m)" 6 | echo "Session IP: $SESSION_IP" 7 | echo 8 | echo -e "\033[33mWelcome to Apptron!\033[0m" 9 | echo 10 | 11 | 12 | if [ -f /project/.apptron/envrc ]; then 13 | source /project/.apptron/envrc 14 | fi 15 | 16 | if [ -z "$SHELL" ]; then 17 | export SHELL=/bin/sh 18 | fi 19 | 20 | setsid /bin/sh -c "exec $SHELL /dev/ttyS0 2>&1" -------------------------------------------------------------------------------- /system/bin/open: -------------------------------------------------------------------------------- 1 | #!/bin/busybox sh 2 | if [ -z "$1" ]; then 3 | echo "Error: No filepath provided." >&2 4 | exit 1 5 | fi 6 | 7 | filepath=$(realpath $1) 8 | 9 | if [ -d $filepath ]; then 10 | echo "cmd open-folder $filepath" >> /ctl 11 | exit 0 12 | fi 13 | 14 | if [ -f $filepath ]; then 15 | if [ "${filepath##*.}" = "md" ]; then 16 | echo "cmd open-preview $filepath" >> /ctl 17 | else 18 | echo "cmd open-file $filepath" >> /ctl 19 | fi 20 | exit 0 21 | fi 22 | 23 | echo "Error: $filepath is not a file or directory." >&2 24 | exit 1 -------------------------------------------------------------------------------- /system/cmd/aptn/go.mod: -------------------------------------------------------------------------------- 1 | module tractor.dev/apptron/system/cmd/aptn 2 | 3 | go 1.25.0 4 | 5 | replace github.com/hugelgupf/p9 => github.com/progrium/p9 v0.0.0-20251108235831-1c1dfeb38c1e 6 | 7 | require ( 8 | github.com/hanwen/go-fuse/v2 v2.7.2 9 | tractor.dev/toolkit-go v0.0.0-20250103001615-9a6753936c19 10 | tractor.dev/wanix v0.0.0-20251115003118-a20016a3b9f1 11 | ) 12 | 13 | require ( 14 | github.com/hugelgupf/p9 v0.3.1-0.20240118043522-6f4f11e5296e // indirect 15 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect 16 | golang.org/x/net v0.39.0 // indirect 17 | golang.org/x/sys v0.38.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /extension/preview/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "target": "ES2020", 5 | "outDir": "dist", 6 | "lib": [ 7 | "ES2020", "WebWorker" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true /* enable all strict type-checking options */ 12 | /* Additional Checks */ 13 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 14 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 15 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /extension/system/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "target": "ES2020", 5 | "outDir": "dist", 6 | "lib": [ 7 | "ES2020", "WebWorker" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true /* enable all strict type-checking options */ 12 | /* Additional Checks */ 13 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 14 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 15 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /system/cmd/aptn/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "tractor.dev/toolkit-go/engine/cli" 9 | ) 10 | 11 | var Version = "dev" 12 | 13 | func main() { 14 | log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) 15 | 16 | root := &cli.Command{ 17 | Version: Version, 18 | Usage: "aptn", 19 | Long: `aptn provides utilities for Apptron environments`, 20 | } 21 | 22 | root.AddCommand(execCmd()) 23 | root.AddCommand(fuseCmd()) 24 | root.AddCommand(portsCmd()) 25 | root.AddCommand(shm9pCmd()) 26 | root.AddCommand(shmtestCmd()) 27 | 28 | if err := cli.Execute(context.Background(), root, os.Args[1:]); err != nil { 29 | log.Fatal(err) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module apptron.dev 2 | 3 | go 1.25.0 4 | 5 | replace golang.org/x/sys => github.com/progrium/sys-wasm v0.0.0-20240620081741-5ccc4fc17421 6 | 7 | replace github.com/hugelgupf/p9 => github.com/progrium/p9 v0.0.0-20251108235831-1c1dfeb38c1e 8 | 9 | // replace tractor.dev/wanix => ../wanix 10 | 11 | require ( 12 | github.com/hugelgupf/p9 v0.3.1-0.20240118043522-6f4f11e5296e 13 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 14 | tractor.dev/wanix v0.0.0-20251203231535-1ea06a9be861 15 | ) 16 | 17 | require ( 18 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 19 | github.com/mitchellh/mapstructure v1.5.0 // indirect 20 | github.com/x448/float16 v0.8.4 // indirect 21 | golang.org/x/net v0.47.0 // indirect 22 | golang.org/x/sys v0.38.0 // indirect 23 | tractor.dev/toolkit-go v0.0.0-20250103001615-9a6753936c19 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /worker/go.mod: -------------------------------------------------------------------------------- 1 | module apptron.dev/worker 2 | 3 | go 1.24.5 4 | 5 | require ( 6 | github.com/gorilla/websocket v1.5.3 7 | github.com/progrium/go-netstack v0.0.0-20240720002214-37b2b8227b91 8 | ) 9 | 10 | require ( 11 | github.com/apparentlymart/go-cidr v1.1.0 // indirect 12 | github.com/google/btree v1.1.2 // indirect 13 | github.com/google/gopacket v1.1.19 // indirect 14 | github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c // indirect 15 | github.com/miekg/dns v1.1.58 // indirect 16 | github.com/pkg/errors v0.9.1 // indirect 17 | github.com/sirupsen/logrus v1.9.3 // indirect 18 | golang.org/x/mod v0.18.0 // indirect 19 | golang.org/x/net v0.26.0 // indirect 20 | golang.org/x/sync v0.7.0 // indirect 21 | golang.org/x/sys v0.21.0 // indirect 22 | golang.org/x/time v0.5.0 // indirect 23 | golang.org/x/tools v0.22.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /extension/preview/esbuild.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | const production = process.argv.includes('--production'); 3 | const watch = process.argv.includes('--watch'); 4 | 5 | async function main() { 6 | const ctx = await esbuild.context({ 7 | entryPoints: [ 8 | 'src/web/extension.ts' 9 | ], 10 | bundle: true, 11 | format: 'cjs', 12 | minify: production, 13 | sourcemap: !production, 14 | sourcesContent: false, 15 | platform: 'browser', 16 | outdir: 'dist/web', 17 | external: ['vscode'], 18 | logLevel: 'silent', 19 | // Node.js global to browser globalThis 20 | define: { 21 | global: 'globalThis', 22 | }, 23 | }); 24 | if (watch) { 25 | await ctx.watch(); 26 | } else { 27 | await ctx.rebuild(); 28 | await ctx.dispose(); 29 | } 30 | } 31 | 32 | main().catch(e => { 33 | console.error(e); 34 | process.exit(1); 35 | }); 36 | -------------------------------------------------------------------------------- /system/etc/profile: -------------------------------------------------------------------------------- 1 | export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 2 | export PAGER=less 3 | umask 022 4 | 5 | export SESSION_IP=$(ifconfig eth0 | grep "inet addr" | cut -d: -f2 | cut -d' ' -f1) 6 | 7 | for script in /etc/profile.d/*.sh ; do 8 | if [ -r "$script" ] ; then 9 | . "$script" 10 | fi 11 | done 12 | unset script 13 | 14 | # use nicer PS1 for bash and busybox ash 15 | if [ -n "$BASH_VERSION" -o "$BB_ASH_VERSION" ]; then 16 | if [ -n "$USER" ]; then 17 | export PS1="$USER:\w $ " 18 | else 19 | export PS1="(anonymous):\w $ " 20 | fi 21 | fi 22 | 23 | if [ -f "/home/$USER/.profile" ]; then 24 | . "/home/$USER/.profile" 25 | fi 26 | 27 | # default to cd to the home directory 28 | if [ -n "$USER" ]; then 29 | cd ~ 30 | fi 31 | 32 | # cd to the project directory if it exists 33 | if [ -d /project ]; then 34 | cd /project 35 | fi 36 | 37 | -------------------------------------------------------------------------------- /extension/system/esbuild.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | const production = process.argv.includes('--production'); 3 | const watch = process.argv.includes('--watch'); 4 | 5 | async function main() { 6 | const ctx = await esbuild.context({ 7 | entryPoints: [ 8 | 'src/web/extension.ts' 9 | ], 10 | bundle: true, 11 | format: 'cjs', 12 | minify: production, 13 | sourcemap: !production, 14 | sourcesContent: false, 15 | platform: 'browser', 16 | outdir: 'dist/web', 17 | external: ['vscode', 'util', 'worker_threads'], 18 | logLevel: 'silent', 19 | // Node.js global to browser globalThis 20 | define: { 21 | global: 'globalThis', 22 | }, 23 | }); 24 | if (watch) { 25 | await ctx.watch(); 26 | } else { 27 | await ctx.rebuild(); 28 | await ctx.dispose(); 29 | } 30 | } 31 | 32 | main().catch(e => { 33 | console.error(e); 34 | process.exit(1); 35 | }); 36 | -------------------------------------------------------------------------------- /system/bin/init: -------------------------------------------------------------------------------- 1 | #!/bin/busybox sh 2 | 3 | mount -t proc proc /proc 4 | mount -t sysfs sysfs /sys 5 | mount -t binfmt_misc none /proc/sys/fs/binfmt_misc 6 | 7 | # register wasm binary format 8 | echo ':wasm:M::\x00\x61\x73\x6d::/bin/wexec:' > /proc/sys/fs/binfmt_misc/register 9 | 10 | # configure networking 11 | ifconfig eth0 up 12 | ifconfig lo up 13 | udhcpc -i eth0 -s /bin/post-dhcp 14 | 15 | source /etc/profile # loads apptron.sh for ENV_UUID, SESSION_IP 16 | 17 | if [ -f /project/.apptron/envbuild ]; then 18 | env_root=/web/idbfs/apptron/env/$ENV_UUID/root 19 | if [ ! -e $env_root ] || [ /project/.apptron/envbuild -nt $env_root ]; then 20 | echo 21 | exec /bin/rebuild 22 | fi 23 | fi 24 | 25 | if [ ! -f /web/idbfs/apptron/.welcomed ]; then 26 | /bin/open /apptron/WELCOME.md 27 | touch /web/idbfs/apptron/.welcomed 28 | fi 29 | 30 | /bin/aptn ports & 31 | 32 | exec /bin/start 33 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "apptron" 2 | compatibility_date = "2025-08-21" 3 | main = "./worker/src/worker.ts" 4 | routes = [ 5 | { pattern = "*.apptron.dev/*", zone_name = "apptron.dev" }, 6 | { pattern = "apptron.dev/*", zone_name = "apptron.dev" }, 7 | { pattern = "*.aptn.pub/*", zone_name = "aptn.pub" }, 8 | ] 9 | 10 | [assets] 11 | directory = "./assets" 12 | binding = "assets" 13 | run_worker_first = [ 14 | "/signin", 15 | "/signout", 16 | "/dashboard", 17 | "/shell", 18 | "/debug", 19 | ] 20 | 21 | [observability.logs] 22 | enabled = true 23 | 24 | [[containers]] 25 | class_name = "Session" 26 | image = "./Dockerfile" 27 | max_instances = 1 28 | instance_type = "standard-4" 29 | 30 | [[durable_objects.bindings]] 31 | class_name = "Session" 32 | name = "session" 33 | 34 | [[migrations]] 35 | new_sqlite_classes = [ "Session" ] 36 | tag = "v1" 37 | 38 | [[r2_buckets]] 39 | binding = "bucket" 40 | bucket_name = "apptron-dev" 41 | -------------------------------------------------------------------------------- /assets/com/account.html: -------------------------------------------------------------------------------- 1 | 2 | 8 | 18 |
19 |

Account

20 | 21 |
22 |
23 |
24 | 25 |
26 |
27 | -------------------------------------------------------------------------------- /assets/img/loader.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /worker/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "router", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "@cloudflare/containers": "^0.0.25", 9 | "modern-tar": "^0.5.4" 10 | } 11 | }, 12 | "node_modules/@cloudflare/containers": { 13 | "version": "0.0.25", 14 | "resolved": "https://registry.npmjs.org/@cloudflare/containers/-/containers-0.0.25.tgz", 15 | "integrity": "sha512-41sCUwGPhMMQI/kwksEZXNWe/GZdQoIy8QlgO4NAF68M4y4NN41IXeQ4n2jn4iJ7tHaV4noe7RI75b7T6h2JGg==", 16 | "license": "ISC" 17 | }, 18 | "node_modules/modern-tar": { 19 | "version": "0.5.4", 20 | "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.5.4.tgz", 21 | "integrity": "sha512-WqxWd82TgUbnindiHcXV8+H6qS0MlN5zrgwKm2gD5M+JyHyCk22xw3RLfpR8cHrQCQwsNIxQu+ve0SumlaLGQQ==", 22 | "license": "MIT", 23 | "engines": { 24 | "node": ">=18.0.0" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Apptron 2 | 3 | on: 4 | push: 5 | branches: 6 | - release 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | name: Deploy to Cloudflare 12 | environment: 13 | name: 'production' 14 | url: https://apptron.dev 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v5 22 | with: 23 | node-version: 'lts/*' 24 | check-latest: true 25 | cache: 'npm' 26 | cache-dependency-path: worker/package-lock.json 27 | 28 | - name: Build project 29 | run: | 30 | make all 31 | 32 | - name: Deploy to Cloudflare Workers 33 | uses: cloudflare/wrangler-action@v3 34 | id: deploy 35 | with: 36 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 37 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 38 | wranglerVersion: 4.43.0 39 | command: deploy 40 | -------------------------------------------------------------------------------- /worker/src/auth.ts: -------------------------------------------------------------------------------- 1 | 2 | interface ValidationResponse { 3 | is_valid: boolean; 4 | } 5 | 6 | export async function validateToken(hankoApiUrl: string, token: string): Promise { 7 | if (!token || token.length === 0) { 8 | return false; 9 | } 10 | 11 | try { 12 | const response = await fetch(`${hankoApiUrl}/sessions/validate`, { 13 | method: 'POST', 14 | headers: { 15 | 'Content-Type': 'application/json', 16 | }, 17 | body: JSON.stringify({ session_token: token }), 18 | redirect: 'manual', 19 | }); 20 | 21 | if (!response.ok) { 22 | return false; 23 | } 24 | 25 | const validationData = await response.json() as ValidationResponse; 26 | return validationData.is_valid; 27 | } catch (error) { 28 | console.error('Token validation error:', error); 29 | return false; 30 | } 31 | } 32 | 33 | export function parseJWT(token: string): Record { 34 | const base64Url = token.split('.')[1]; 35 | const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); 36 | return JSON.parse(atob(base64)); 37 | } -------------------------------------------------------------------------------- /.github/workflows/kernel.yml: -------------------------------------------------------------------------------- 1 | name: Build Kernel 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - 'system/kernel/**' 8 | 9 | jobs: 10 | build-amd64: 11 | name: Build Linux Kernel 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | packages: write 16 | steps: 17 | - name: Display system information 18 | run: | 19 | uname -a 20 | 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Login to GitHub Container Registry 25 | uses: docker/login-action@v3 26 | with: 27 | registry: ghcr.io 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Build and push to GHCR 35 | uses: docker/build-push-action@v6 36 | with: 37 | context: system/kernel 38 | platforms: linux/amd64 39 | push: true 40 | tags: ghcr.io/${{ github.repository }}:kernel 41 | -------------------------------------------------------------------------------- /extension/preview/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apptron-preview", 3 | "displayName": "Preview", 4 | "publisher": "progrium", 5 | "description": "", 6 | "version": "0.1.0", 7 | "engines": { 8 | "vscode": "^1.66.0" 9 | }, 10 | "categories": [ 11 | "Other" 12 | ], 13 | "activationEvents": [ 14 | "onCommand:preview.openBrowser" 15 | ], 16 | "browser": "./dist/web/extension.js", 17 | "scripts": { 18 | "compile-web": "npm run check-types && node esbuild.js", 19 | "watch-web": "node esbuild.js --watch", 20 | "check-types": "tsc --noEmit" 21 | }, 22 | "devDependencies": { 23 | "@types/vscode": "^1.90.0", 24 | "@vscode/test-web": "^0.0.54", 25 | "esbuild": "^0.21.5", 26 | "typescript": "^5.4.5" 27 | }, 28 | "contributes": { 29 | "commands": [ 30 | { 31 | "command": "preview.openBrowser", 32 | "title": "Open Browser Preview", 33 | "category": "Preview" 34 | } 35 | ], 36 | "menus": { 37 | "commandPalette": [ 38 | { 39 | "command": "preview.openBrowser", 40 | "when": "true" 41 | } 42 | ] 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /system/bin/rebuild: -------------------------------------------------------------------------------- 1 | #!/bin/busybox sh 2 | 3 | set -euo pipefail 4 | 5 | env_root=/web/idbfs/apptron/env/$ENV_UUID/root 6 | echo "Building environment..." 7 | 8 | mount -o bind /dev /envbuild/dev 9 | mount -t proc proc /envbuild/proc 10 | mount -t sysfs sys /envbuild/sys 11 | 12 | cp /project/.apptron/envbuild /envbuild/.envbuild 13 | chroot /envbuild /bin/sh -c ". .envbuild" 14 | 15 | umount /envbuild/dev 16 | umount /envbuild/proc 17 | umount /envbuild/sys 18 | 19 | echo "Saving environment ..." 20 | rm /envbuild/.envbuild 21 | rm -rf $env_root 22 | echo cp envbuild "${env_root:1}" >> /ctl 23 | rm -rf /envbuild/* 24 | echo "Build complete" 25 | echo 26 | 27 | # next boot will use the new environment, 28 | # but here we will chroot into it 29 | mount -o bind /dev $env_root/dev 30 | mount -o bind /home $env_root/home 31 | # mount -o bind /project $env_root/project 32 | # mount -o bind /web $env_root/web 33 | mount -t proc proc $env_root/proc 34 | mount -t sysfs sys $env_root/sys 35 | cp /etc/resolv.conf $env_root/etc/resolv.conf 36 | cp /etc/profile.d/apptron.sh $env_root/etc/profile.d/apptron.sh 37 | 38 | # read -p "Press enter to continue" 39 | 40 | chroot $env_root /bin/start -------------------------------------------------------------------------------- /assets/img/icons/info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /system/cmd/aptn/shm9p.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/hugelgupf/p9/p9" 9 | "tractor.dev/toolkit-go/engine/cli" 10 | "tractor.dev/wanix/fs/localfs" 11 | "tractor.dev/wanix/fs/p9kit" 12 | "tractor.dev/wanix/vm/v86/shm" 13 | ) 14 | 15 | func shm9pCmd() *cli.Command { 16 | return &cli.Command{ 17 | Usage: "shm9p", 18 | Short: "run 9p server of the root filesystem via shared memory pipe", 19 | Run: runShm9P, 20 | } 21 | } 22 | 23 | func runShm9P(ctx *cli.Context, args []string) { 24 | sch, err := shm.NewSharedChannel() 25 | if err != nil { 26 | fmt.Fprintf(os.Stderr, "Failed to create channel: %v\n", err) 27 | os.Exit(1) 28 | } 29 | defer sch.Close() 30 | 31 | dirfs, err := localfs.New("/") 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | p9srv := p9.NewServer(p9kit.Attacher(dirfs)) //, p9.WithServerLogger(ulog.Log)) 36 | go func() { 37 | err := os.WriteFile("/run/shm9p.lock", []byte(""), 0644) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | }() 42 | defer func() { 43 | os.Remove("/run/shm9p.lock") 44 | }() 45 | if err := p9srv.Handle(sch, sch); err != nil { 46 | log.Fatal(err) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /assets/signin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Apptron 6 | 7 | 8 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /assets/com/sponsor.html: -------------------------------------------------------------------------------- 1 | 2 | 8 |
9 |

Enjoying Apptron?

10 | 11 |
12 |
13 |
14 |

15 | We're a small team and we need your help to keep Apptron development sustainable. 16 |

17 |

18 | By sponsoring, you'll help us build and maintain Apptron, as well as get access to exclusive features and support. 19 |

20 |

21 | Together we can show the world that software innovation can still happen without relying on big tech. 22 |

23 |
24 | 30 |
31 | -------------------------------------------------------------------------------- /extension/system/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apptron-system", 3 | "displayName": "Apptron", 4 | "publisher": "progrium", 5 | "description": "", 6 | "version": "0.2.0", 7 | "engines": { 8 | "vscode": "^1.66.0" 9 | }, 10 | "categories": [ 11 | "Other", 12 | "Themes" 13 | ], 14 | "enabledApiProposals": [ 15 | "ipc" 16 | ], 17 | "activationEvents": [ 18 | "onFileSystem:wanix" 19 | ], 20 | "extensionKind": [ 21 | "web" 22 | ], 23 | "browser": "./dist/web/extension.js", 24 | "scripts": { 25 | "compile-web": "npm run check-types && node esbuild.js", 26 | "watch-web": "node esbuild.js --watch", 27 | "check-types": "tsc --noEmit" 28 | }, 29 | "devDependencies": { 30 | "@types/vscode": "^1.90.0", 31 | "@vscode/test-web": "^0.0.54", 32 | "esbuild": "^0.25.9", 33 | "typescript": "^5.4.5" 34 | }, 35 | "contributes": { 36 | "themes": [ 37 | { 38 | "label": "Tractor Dark", 39 | "uiTheme": "tractor-dark", 40 | "path": "./themes/Tractor-color-theme.json" 41 | } 42 | ], 43 | "commands": [ 44 | { 45 | "command": "apptron.open", 46 | "title": "Open File or Folder" 47 | } 48 | ] 49 | }, 50 | "dependencies": { 51 | "@progrium/duplex": "^0.2.1" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /assets/com/share_embed.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 21 | 22 |
23 |
24 | 25 | 26 |
27 | 28 | 34 |
35 | 36 | -------------------------------------------------------------------------------- /assets/com/share.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 26 | 27 | 28 |
29 |
30 | 31 | 32 | 33 |
34 | 35 |
36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /assets/com/feedback.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 28 |
29 |

Give us feedback

30 | 31 |
32 |
33 |

Feedback, ideas, bugs, tell us anything!

34 | 35 | 39 |
40 | -------------------------------------------------------------------------------- /assets/_frame.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Apptron 6 | 8 | 9 | 30 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /worker/src/public.ts: -------------------------------------------------------------------------------- 1 | import { PUBLISH_DOMAINS } from "./config"; 2 | import { Context } from "./context"; 3 | import { isLocal } from "./util"; 4 | import * as projects from "./projects"; 5 | 6 | export async function handle(req: Request, env: any, ctx: Context) { 7 | const url = new URL(req.url); 8 | let domain = undefined; 9 | let username = undefined; 10 | if (isLocal(env)) { 11 | domain = url.pathname.split("/")[1]; 12 | username = domain.split(".")[0]; 13 | url.pathname = url.pathname.slice(domain.length + 1); 14 | } else { 15 | domain = url.host; 16 | username = domain.split(".")[0]; 17 | } 18 | let envName = url.pathname.split("/")[1]; 19 | url.pathname = url.pathname.slice(envName.length + 1); 20 | 21 | const project = await projects.getByName(env, username, envName); 22 | if (!project) { 23 | return new Response('Not found', { status: 404 }); 24 | } 25 | 26 | let objectKey = `/env/${project["uuid"]}/public${url.pathname}`; 27 | let object = await env.bucket.get(objectKey); 28 | if (!object || object.customMetadata["Content-Type"] === "application/x-directory") { 29 | objectKey = `/env/${project["uuid"]}/public${url.pathname}/index.html`.replace(/\/{2,}/g, "/"); 30 | object = await env.bucket.get(objectKey); 31 | if (!object) { 32 | object = await env.bucket.get(`/env/${project["uuid"]}/public/404.html`); 33 | if (object) { 34 | return new Response(object.body, { 35 | headers: { 36 | 'Content-Type': object.httpMetadata.contentType || 'text/html', 37 | }, 38 | status: 404, 39 | }); 40 | } else { 41 | return new Response('Not found', { status: 404 }); 42 | } 43 | } 44 | } 45 | 46 | return new Response(object.body, { 47 | headers: { 48 | 'Content-Type': object.httpMetadata.contentType || 'text/html', 49 | }, 50 | }); 51 | } -------------------------------------------------------------------------------- /assets/img/transparent-logomark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/bundles.html: -------------------------------------------------------------------------------- 1 | 2 | 67 | -------------------------------------------------------------------------------- /system/cmd/aptn/go.sum: -------------------------------------------------------------------------------- 1 | github.com/hanwen/go-fuse/v2 v2.7.2 h1:SbJP1sUP+n1UF8NXBA14BuojmTez+mDgOk0bC057HQw= 2 | github.com/hanwen/go-fuse/v2 v2.7.2/go.mod h1:ugNaD/iv5JYyS1Rcvi57Wz7/vrLQJo10mmketmoef48= 3 | github.com/hugelgupf/socketpair v0.0.0-20230822150718-707395b1939a h1:Nq7wDsqsVBUBfGn8yB1M028ShWTKTtZBcafaTJ35N0s= 4 | github.com/hugelgupf/socketpair v0.0.0-20230822150718-707395b1939a/go.mod h1:71Bqb5Fh9zPHF8jwdmMEmJObzr25Mx5pWLbDBMMEn6E= 5 | github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4= 6 | github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= 7 | github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= 8 | github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= 9 | github.com/progrium/p9 v0.0.0-20251108235831-1c1dfeb38c1e h1:+qqlsH4r/1Y9O1F/WPklEtzDYnbYeWEmjFAY35O+SQ8= 10 | github.com/progrium/p9 v0.0.0-20251108235831-1c1dfeb38c1e/go.mod h1:LoNwfBWP+QlCkjS1GFNylCthRIk/TkMZd6ICTbC+hrI= 11 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= 12 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= 13 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 14 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 15 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 16 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 17 | tractor.dev/toolkit-go v0.0.0-20250103001615-9a6753936c19 h1:ODcU8zhYvUtfGJ3KeMhtlA942ScAQMT3KU0O1oBg8Sw= 18 | tractor.dev/toolkit-go v0.0.0-20250103001615-9a6753936c19/go.mod h1:vI9Jf9tepHLrUqGQf7XZuRcQySNajWRKBjPD4+Ay72I= 19 | tractor.dev/wanix v0.0.0-20251110223705-0a30d5033968 h1:wRCP6tHADhv1fa9YCcAOrn7TyHSG/DACo3tG34Qvyf8= 20 | tractor.dev/wanix v0.0.0-20251110223705-0a30d5033968/go.mod h1:qcpx285zeq9TvSqky2aHaFe531DYOAvgq5TWiclhn+o= 21 | tractor.dev/wanix v0.0.0-20251115003118-a20016a3b9f1 h1:XUT6E1GmFApla5BDorbjZEvyebOdWgulxoPG3Hdra/M= 22 | tractor.dev/wanix v0.0.0-20251115003118-a20016a3b9f1/go.mod h1:SNCIfmlU9noiVhntMcTa6Hxcr0jdINRdYi6ZDpau/oU= 23 | -------------------------------------------------------------------------------- /worker/src/context.ts: -------------------------------------------------------------------------------- 1 | import { HOST_DOMAIN } from "./config"; 2 | import { parseJWT } from "./auth"; 3 | 4 | export interface Context { 5 | tokenRaw?: string; 6 | tokenJWT?: Record; 7 | userUUID?: string; 8 | username?: string; 9 | 10 | userDomain: boolean; 11 | envDomain: boolean; 12 | portDomain: boolean; 13 | subdomain?: string; // username or env UUID 14 | } 15 | 16 | export function parseContext(req: Request, env: any): Context { 17 | const url = new URL(req.url); 18 | const ctx: Context = { 19 | userDomain: false, 20 | envDomain: false, 21 | portDomain: false, 22 | }; 23 | 24 | ctx.tokenRaw = url.searchParams.get("token") || undefined; 25 | if (!ctx.tokenRaw) { 26 | const cookie = req.headers.get("cookie") || ""; 27 | const match = cookie.match(/hanko=([^;]+)/); 28 | if (match) { 29 | ctx.tokenRaw = match[1] || undefined; 30 | } 31 | } 32 | if (!ctx.tokenRaw) { 33 | ctx.tokenRaw = req.headers.get("Authorization")?.split(" ")[1] || undefined; 34 | } 35 | 36 | if (ctx.tokenRaw) { 37 | ctx.tokenJWT = parseJWT(ctx.tokenRaw); 38 | ctx.userUUID = ctx.tokenJWT["sub"]; // should be user_id 39 | ctx.username = ctx.tokenJWT["username"]; 40 | } 41 | 42 | if (url.host.endsWith("." + HOST_DOMAIN)) { 43 | const subdomain = url.host.slice(0, -("." + HOST_DOMAIN).length); 44 | if (subdomain.startsWith("tcp-") && subdomain.split("-").length === 4) { 45 | ctx.portDomain = true; 46 | } else if (subdomain.length >= 32) { 47 | ctx.envDomain = true; 48 | } else { 49 | ctx.userDomain = true; 50 | } 51 | ctx.subdomain = subdomain; 52 | } 53 | 54 | if (url.searchParams.get("env")) { 55 | ctx.subdomain = url.searchParams.get("env") || undefined; 56 | ctx.envDomain = true; 57 | } else if (url.searchParams.get("user")) { 58 | ctx.subdomain = url.searchParams.get("user") || undefined; 59 | ctx.userDomain = true; 60 | } else if (url.searchParams.get("port")) { 61 | ctx.portDomain = true; 62 | ctx.subdomain = url.searchParams.get("port") || undefined; 63 | } 64 | 65 | return ctx; 66 | } 67 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VSCODE_URL ?= https://github.com/progrium/vscode-web/releases/download/v1/vscode-web-1.103.2.zip 2 | DOCKER_CMD ?= $(shell command -v podman || command -v docker) 3 | 4 | all: assets/vscode worker/node_modules extension/system/dist assets/wanix.min.js assets/wanix.wasm 5 | .PHONY: all 6 | 7 | dev: all .env.local 8 | docker rm -f $(shell docker ps -a --filter "name=^workerd-apptron-Session" --format "{{.ID}}") > /dev/null 2>&1 || true 9 | wrangler dev --port=8788 10 | .PHONY: dev 11 | 12 | deploy: all 13 | wrangler deploy 14 | .PHONY: deploy 15 | 16 | wasm: boot.go 17 | GOOS=js GOARCH=wasm go build -o ./assets/wanix.wasm . 18 | .PHONY: wasm 19 | 20 | ext: 21 | cd extension/system && npm run compile-web 22 | .PHONY: ext 23 | 24 | live-install: ./assets/wanix.wasm 25 | @if [ -z "$$ENV_UUID" ]; then \ 26 | echo "ERROR: This is expected to run in an Apptron environment"; \ 27 | exit 1; \ 28 | fi 29 | @if [ -d /web/caches/assets/localhost:8788 ]; then \ 30 | cp ./assets/wanix.wasm /web/caches/assets/localhost:8788/wanix.wasm; \ 31 | else \ 32 | cp ./assets/wanix.wasm /web/caches/assets/$(ENV_UUID).apptron.dev/wanix.wasm; \ 33 | fi 34 | .PHONY: live-install 35 | 36 | clean: 37 | rm -rf assets/vscode 38 | rm -rf worker/node_modules 39 | rm -rf node_modules 40 | rm -rf extension/system/dist 41 | rm -rf extension/system/node_modules 42 | rm -f assets/wanix.wasm 43 | rm -f assets/wanix.debug.wasm 44 | rm -f assets/wanix.js 45 | rm -f assets/wanix.min.js 46 | .PHONY: clean 47 | 48 | .env.local: 49 | cp .env.example .env.local 50 | 51 | assets/vscode: 52 | curl -sL $(VSCODE_URL) -o assets/vscode.zip 53 | mkdir -p .tmp 54 | unzip assets/vscode.zip -d .tmp 55 | mv .tmp/dist/vscode assets/vscode 56 | rm -rf .tmp 57 | rm assets/vscode.zip 58 | 59 | extension/system/dist: extension/system/node_modules 60 | make ext 61 | 62 | extension/system/node_modules: 63 | cd extension/system && npm ci 64 | 65 | worker/node_modules: worker/package.json 66 | cd worker && npm ci 67 | 68 | assets/wanix.wasm: 69 | make wasm 70 | 71 | assets/wanix.min.js: 72 | $(DOCKER_CMD) rm -f apptron-wanix 73 | $(DOCKER_CMD) pull --platform linux/amd64 ghcr.io/tractordev/wanix:runtime 74 | $(DOCKER_CMD) create --name apptron-wanix --platform linux/amd64 ghcr.io/tractordev/wanix:runtime 75 | $(DOCKER_CMD) cp apptron-wanix:/wanix.min.js assets/wanix.min.js 76 | $(DOCKER_CMD) cp apptron-wanix:/wanix.js assets/wanix.js -------------------------------------------------------------------------------- /system/cmd/aptn/shmtest.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "time" 9 | 10 | "tractor.dev/toolkit-go/engine/cli" 11 | "tractor.dev/wanix/vm/v86/shm" 12 | ) 13 | 14 | func shmtestCmd() *cli.Command { 15 | return &cli.Command{ 16 | Usage: "shmtest", 17 | Short: "run throughput test for the shared memory pipe", 18 | Run: runShmTest, 19 | } 20 | } 21 | 22 | func runShmTest(ctx *cli.Context, args []string) { 23 | sch, err := shm.NewSharedChannel() 24 | if err != nil { 25 | fmt.Fprintf(os.Stderr, "Failed to create channel: %v\n", err) 26 | os.Exit(1) 27 | } 28 | defer sch.Close() 29 | 30 | fmt.Println("Shared channel connected!") 31 | 32 | fmt.Println("Starting throughput test...") 33 | if err := ThroughputTest(sch); err != nil { 34 | fmt.Fprintf(os.Stderr, "Throughput test failed: %v\n", err) 35 | os.Exit(1) 36 | } 37 | fmt.Println("Throughput test completed successfully") 38 | 39 | } 40 | 41 | // ThroughputTest measures roundtrip (echo) throughput via the provided stream. 42 | // It writes data to the stream and reads the corresponding echoed data back, 43 | // reporting the transfer rate and latency. 44 | func ThroughputTest(conn io.ReadWriteCloser) error { 45 | const ( 46 | testSize = 1024 * 1024 * 80 // 80 MB total transfer size 47 | chunkSize = 256 * 1024 // 256KB per write 48 | ) 49 | sendBuf := make([]byte, chunkSize) 50 | for i := range sendBuf { 51 | sendBuf[i] = byte(i) 52 | } 53 | recvBuf := make([]byte, chunkSize) 54 | // nwrites := testSize / chunkSize 55 | 56 | start := time.Now() 57 | totalSent := 0 58 | totalRecv := 0 59 | 60 | go func() { 61 | // Write phase. This is actually asynchronous. 62 | for totalSent < testSize { 63 | n, err := conn.Write(sendBuf) 64 | if err != nil { 65 | log.Fatalf("write error: %v", err) 66 | } 67 | totalSent += n 68 | } 69 | }() 70 | 71 | // Read phase (echo expected) 72 | i := 0 73 | for totalRecv < testSize { 74 | n, err := conn.Read(recvBuf) 75 | if err != nil { 76 | return fmt.Errorf("read error: %w", err) 77 | } 78 | totalRecv += n 79 | if i%100 == 0 { 80 | fmt.Println("inner: read:", n, "totalRecv:", totalRecv, "totalSent:", totalSent, "i:", i) 81 | } 82 | i++ 83 | } 84 | 85 | elapsed := time.Since(start) 86 | mb := float64(totalSent) / (1024 * 1024) 87 | mbps := mb / elapsed.Seconds() 88 | fmt.Printf("Throughput: sent %d bytes, recv %d bytes in %.3fs (%.2f MB/s)\n", 89 | totalSent, totalRecv, elapsed.Seconds(), mbps) 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /system/cmd/aptn/fuse.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net" 9 | "os" 10 | "os/signal" 11 | "time" 12 | 13 | "github.com/hugelgupf/p9/p9" 14 | "tractor.dev/toolkit-go/engine/cli" 15 | "tractor.dev/wanix/fs/fusekit" 16 | "tractor.dev/wanix/fs/p9kit" 17 | "tractor.dev/wanix/vm/v86/shm" 18 | ) 19 | 20 | func fuseCmd() *cli.Command { 21 | return &cli.Command{ 22 | Usage: "fuse", 23 | Short: "mount experimental fuse filesystem", 24 | Run: setupFuseFS, 25 | } 26 | } 27 | 28 | func setupFuseFS(ctx *cli.Context, args []string) { 29 | sch, err := shm.NewSharedChannel() 30 | if err != nil { 31 | fmt.Fprintf(os.Stderr, "Failed to create channel: %v\n", err) 32 | os.Exit(1) 33 | } 34 | defer sch.Close() 35 | fsys, err := p9kit.ClientFS(&rwcConn{rwc: sch}, "/", p9.WithMessageSize(512*1024)) 36 | if err != nil { 37 | fmt.Fprintf(os.Stderr, "Failed to create client FS: %v\n", err) 38 | os.Exit(1) 39 | } 40 | os.MkdirAll("/x", 0755) 41 | mount, err := fusekit.Mount(fsys, "/x", context.Background()) 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | defer func() { 46 | if err := mount.Close(); err != nil { 47 | log.Fatal(err) 48 | } 49 | }() 50 | 51 | sigChan := make(chan os.Signal, 1) 52 | signal.Notify(sigChan) 53 | for sig := range sigChan { 54 | if sig == os.Interrupt { 55 | return 56 | } 57 | } 58 | 59 | select {} 60 | } 61 | 62 | // Conn is an adapter that implements net.Conn using an underlying io.ReadWriteCloser. 63 | // LocalAddr/RemoteAddr will be dummy addrs, SetDeadline/Set[Read|Write]Deadline are no-ops. 64 | type rwcConn struct { 65 | rwc io.ReadWriteCloser 66 | } 67 | 68 | func (c *rwcConn) Read(b []byte) (int, error) { 69 | return c.rwc.Read(b) 70 | } 71 | func (c *rwcConn) Write(b []byte) (int, error) { 72 | return c.rwc.Write(b) 73 | } 74 | func (c *rwcConn) Close() error { 75 | return c.rwc.Close() 76 | } 77 | func (c *rwcConn) LocalAddr() (addr net.Addr) { 78 | return dummyAddr("rwc-local") 79 | } 80 | func (c *rwcConn) RemoteAddr() (addr net.Addr) { 81 | return dummyAddr("rwc-remote") 82 | } 83 | func (c *rwcConn) SetDeadline(t time.Time) error { 84 | return nil // not supported 85 | } 86 | func (c *rwcConn) SetReadDeadline(t time.Time) error { 87 | return nil // not supported 88 | } 89 | func (c *rwcConn) SetWriteDeadline(t time.Time) error { 90 | return nil // not supported 91 | } 92 | 93 | type dummyAddr string 94 | 95 | func (a dummyAddr) Network() string { return string(a) } 96 | func (a dummyAddr) String() string { return string(a) } 97 | -------------------------------------------------------------------------------- /assets/lib/dropdown.js: -------------------------------------------------------------------------------- 1 | class Dropdown extends HTMLElement { 2 | connectedCallback() { 3 | const children = this.children; 4 | 5 | if (children.length < 2) { 6 | console.warn('popover-setup requires at least 2 child elements'); 7 | return; 8 | } 9 | 10 | const trigger = children[0]; 11 | const popover = children[1]; 12 | 13 | if (!popover.id) { 14 | popover.id = `popover-${Math.random().toString(36).substr(2, 9)}`; 15 | } 16 | 17 | trigger.setAttribute('aria-haspopup', 'menu'); 18 | trigger.setAttribute('aria-controls', popover.id); 19 | trigger.setAttribute('popovertarget', popover.id); 20 | trigger.setAttribute('popovertargetaction', 'toggle'); 21 | 22 | popover.setAttribute('popover', ''); 23 | 24 | // ===== click-outside handler ===== 25 | // Store handler on the instance so we can remove it later 26 | this._outsideClickHandler = (event) => { 27 | const path = event.composedPath(); 28 | // If click is inside trigger or popover, ignore 29 | if (path.includes(trigger) || path.includes(popover)) return; 30 | // Otherwise hide the popover 31 | if (typeof popover.hidePopover === 'function') { 32 | popover.hidePopover(); 33 | } else { 34 | // Fallback if HTML popover API isn't present 35 | popover.removeAttribute('open'); 36 | } 37 | }; 38 | 39 | popover.addEventListener('toggle', (e) => { 40 | if (e.newState === 'open') { 41 | // Positioning logic 42 | const triggerRect = trigger.getBoundingClientRect(); 43 | const popoverRect = popover.getBoundingClientRect(); 44 | 45 | const align = this.getAttribute('align') || 'left'; 46 | 47 | let marginLeft; 48 | if (align === 'right') { 49 | marginLeft = triggerRect.right - popoverRect.width; 50 | } else { 51 | marginLeft = triggerRect.left; 52 | } 53 | 54 | const marginTop = triggerRect.bottom; 55 | 56 | popover.style.marginLeft = `${marginLeft}px`; 57 | popover.style.marginTop = `${marginTop}px`; 58 | 59 | // Start listening for outside clicks (capture so we run early) 60 | document.addEventListener('pointerdown', this._outsideClickHandler, true); 61 | } else { 62 | // Popover closed: stop listening 63 | document.removeEventListener('pointerdown', this._outsideClickHandler, true); 64 | } 65 | }); 66 | } 67 | 68 | disconnectedCallback() { 69 | // Clean up if the element is removed while open 70 | if (this._outsideClickHandler) { 71 | document.removeEventListener('pointerdown', this._outsideClickHandler, true); 72 | } 73 | } 74 | } 75 | 76 | customElements.define('apptron-dropdown', Dropdown); -------------------------------------------------------------------------------- /assets/lib/toast.js: -------------------------------------------------------------------------------- 1 | // /lib/toast.js 2 | // 3 | // Minimal toast helper that matches the exact HTML structure: 4 | // 5 | //
6 | // 7 | // Message 8 | // 9 | //
10 | 11 | const TOAST_ROOT_ID = "toast-root"; 12 | const DEFAULT_TIMEOUT = 4000; 13 | 14 | // Ensure #toast-root exists 15 | function ensureToastRoot() { 16 | let root = document.getElementById(TOAST_ROOT_ID); 17 | if (root) return root; 18 | 19 | root = document.createElement("div"); 20 | root.id = TOAST_ROOT_ID; 21 | document.body.appendChild(root); 22 | return root; 23 | } 24 | 25 | // Load and clone an external SVG file 26 | async function loadSvg(url, className) { 27 | const resp = await fetch(url); 28 | if (!resp.ok) { 29 | throw new Error(`Failed to load SVG: ${url}`); 30 | } 31 | 32 | const text = await resp.text(); 33 | const template = document.createElement("template"); 34 | template.innerHTML = text.trim(); 35 | 36 | const svg = template.content.querySelector("svg"); 37 | if (!svg) { 38 | throw new Error(`No found in ${url}`); 39 | } 40 | 41 | if (className) { 42 | svg.classList.add(className); 43 | } 44 | 45 | return svg; 46 | } 47 | 48 | export async function showToast( 49 | message, 50 | { 51 | timeout = DEFAULT_TIMEOUT, 52 | icon = "/img/icons/info.svg", 53 | } = {} 54 | ) { 55 | const root = ensureToastRoot(); 56 | 57 | const toast = document.createElement("div"); 58 | toast.className = "toast"; 59 | 60 | // Status icon 61 | let statusIcon; 62 | try { 63 | statusIcon = await loadSvg(icon, "toast-status-icon"); 64 | } catch (err) { 65 | console.error(err); 66 | } 67 | 68 | // Message 69 | const text = document.createElement("span"); 70 | text.className = "toast-text"; 71 | text.textContent = message; 72 | 73 | // Close button 74 | const closeBtn = document.createElement("button"); 75 | closeBtn.type = "button"; 76 | closeBtn.setAttribute("aria-label", "Close"); 77 | 78 | let closeIcon; 79 | try { 80 | closeIcon = await loadSvg("/img/icons/close.svg"); 81 | closeBtn.appendChild(closeIcon); 82 | } catch (err) { 83 | console.error(err); 84 | closeBtn.textContent = "×"; 85 | } 86 | 87 | closeBtn.addEventListener("click", () => { 88 | toast.remove(); 89 | }); 90 | 91 | // Assemble toast 92 | if (statusIcon) toast.appendChild(statusIcon); 93 | toast.appendChild(text); 94 | toast.appendChild(closeBtn); 95 | 96 | root.appendChild(toast); 97 | 98 | // Auto-dismiss 99 | if (timeout > 0) { 100 | setTimeout(() => { 101 | toast.remove(); 102 | }, timeout); 103 | } 104 | 105 | return { 106 | el: toast, 107 | dismiss() { 108 | toast.remove(); 109 | }, 110 | }; 111 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BUILDPLATFORM 2 | ARG TARGETPLATFORM 3 | ARG LINUX_386=linux/386 4 | ARG LINUX_AMD64=linux/amd64 5 | ARG GO_VERSION=1.25.0 6 | ARG TINYGO_VERSION=0.36.0 7 | ARG ALPINE_VERSION=3.22 8 | 9 | FROM --platform=$LINUX_AMD64 ghcr.io/tractordev/apptron:kernel AS kernel 10 | FROM --platform=$LINUX_AMD64 ghcr.io/progrium/v86:latest AS v86 11 | 12 | 13 | FROM golang:$GO_VERSION-alpine AS aptn-go 14 | WORKDIR /build 15 | COPY system/cmd/aptn/go.mod system/cmd/aptn/go.sum ./ 16 | RUN go mod download 17 | COPY system/cmd/aptn ./ 18 | RUN GOOS=linux GOARCH=386 CGO_ENABLED=0 go build -o aptn *.go 19 | 20 | 21 | FROM tinygo/tinygo:$TINYGO_VERSION AS aptn-tinygo 22 | WORKDIR /build 23 | COPY system/cmd/aptn ./ 24 | RUN GOOS=linux GOARCH=386 tinygo build -o aptn *.go 25 | 26 | 27 | FROM --platform=$LINUX_386 docker.io/i386/alpine:$ALPINE_VERSION AS rootfs 28 | RUN apk add --no-cache fuse make git esbuild 29 | COPY --from=aptn-go /build/aptn /bin/aptn 30 | COPY ./system/apptron/* /apptron/ 31 | COPY ./system/bin/* /bin/ 32 | COPY ./system/etc/* /etc/ 33 | 34 | 35 | FROM alpine:$ALPINE_VERSION AS bundle-base 36 | RUN mkdir -p /bundles 37 | RUN apk add --no-cache brotli 38 | 39 | FROM bundle-base AS bundle-go 40 | ARG GO_VERSION 41 | ENV GO_VERSION=$GO_VERSION 42 | RUN wget https://go.dev/dl/go${GO_VERSION}.linux-386.tar.gz \ 43 | && tar -xzf go${GO_VERSION}.linux-386.tar.gz go/src go/pkg go/bin go/lib go/misc \ 44 | && rm go${GO_VERSION}.linux-386.tar.gz 45 | 46 | FROM bundle-go AS bundle-goroot 47 | RUN tar -C /go -cf /bundles/goroot.tar . && brotli -j /bundles/goroot.tar 48 | 49 | FROM bundle-go AS bundle-gocache-386 50 | ENV GOCACHE=/gocache 51 | ENV GOARCH=386 52 | RUN /go/bin/go telemetry off && /go/bin/go build std 53 | RUN tar -C /gocache -cf /bundles/gocache-386.tar . && brotli -j /bundles/gocache-386.tar 54 | 55 | FROM bundle-go AS bundle-gocache-wasm 56 | ENV GOCACHE=/gocache 57 | ENV GOARCH=wasm 58 | ENV GOOS=js 59 | RUN /go/bin/go telemetry off && /go/bin/go build std 60 | RUN tar -C /gocache -cf /bundles/gocache-wasm.tar . && brotli -j /bundles/gocache-wasm.tar 61 | 62 | FROM bundle-base AS bundle-sys 63 | COPY --from=rootfs / /bundle/rootfs 64 | COPY --from=kernel /bzImage /bundle/kernel/bzImage 65 | COPY --from=v86 /v86.wasm /bundle/v86/v86.wasm 66 | COPY --from=v86 /bios/seabios.bin /bundle/v86/seabios.bin 67 | COPY --from=v86 /bios/vgabios.bin /bundle/v86/vgabios.bin 68 | RUN tar -C /bundle -czf /bundles/sys.tar.gz . 69 | 70 | 71 | FROM golang:$GO_VERSION-alpine AS worker-build 72 | RUN apk add --no-cache git 73 | COPY worker/go.mod worker/go.sum ./ 74 | RUN go mod download 75 | COPY worker . 76 | RUN CGO_ENABLED=0 go build -o /worker ./cmd/worker 77 | 78 | 79 | FROM scratch AS worker 80 | COPY --from=bundle-sys /bundles/* /bundles/ 81 | COPY --from=bundle-goroot /bundles/* /bundles/ 82 | COPY --from=bundle-gocache-386 /bundles/* /bundles/ 83 | COPY --from=bundle-gocache-wasm /bundles/* /bundles/ 84 | COPY --from=worker-build /worker /worker 85 | EXPOSE 8080 86 | CMD ["/worker"] -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 2 | github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 3 | github.com/hugelgupf/socketpair v0.0.0-20230822150718-707395b1939a h1:Nq7wDsqsVBUBfGn8yB1M028ShWTKTtZBcafaTJ35N0s= 4 | github.com/hugelgupf/socketpair v0.0.0-20230822150718-707395b1939a/go.mod h1:71Bqb5Fh9zPHF8jwdmMEmJObzr25Mx5pWLbDBMMEn6E= 5 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 6 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 7 | github.com/progrium/p9 v0.0.0-20251108235831-1c1dfeb38c1e h1:+qqlsH4r/1Y9O1F/WPklEtzDYnbYeWEmjFAY35O+SQ8= 8 | github.com/progrium/p9 v0.0.0-20251108235831-1c1dfeb38c1e/go.mod h1:LoNwfBWP+QlCkjS1GFNylCthRIk/TkMZd6ICTbC+hrI= 9 | github.com/progrium/sys-wasm v0.0.0-20240620081741-5ccc4fc17421 h1:rZxY8XmF6OkSGkgyBabEVxOkpq8uoHiA7ud7Gf5Xd+Q= 10 | github.com/progrium/sys-wasm v0.0.0-20240620081741-5ccc4fc17421/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 11 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= 12 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= 13 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 14 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 15 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 16 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 17 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 18 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 19 | tractor.dev/toolkit-go v0.0.0-20250103001615-9a6753936c19 h1:ODcU8zhYvUtfGJ3KeMhtlA942ScAQMT3KU0O1oBg8Sw= 20 | tractor.dev/toolkit-go v0.0.0-20250103001615-9a6753936c19/go.mod h1:vI9Jf9tepHLrUqGQf7XZuRcQySNajWRKBjPD4+Ay72I= 21 | tractor.dev/wanix v0.0.0-20251108165506-ba55d40efd2c h1:f4HZ4O0Vcs9/RBN08+2+Ivmkute4JV8MEYBMQgnwwRY= 22 | tractor.dev/wanix v0.0.0-20251108165506-ba55d40efd2c/go.mod h1:qcpx285zeq9TvSqky2aHaFe531DYOAvgq5TWiclhn+o= 23 | tractor.dev/wanix v0.0.0-20251119011735-746be78ff95c h1:NJFneN63IJJTVidnOiZqnyty2Be0rP5PSX8yH4A2fPI= 24 | tractor.dev/wanix v0.0.0-20251119011735-746be78ff95c/go.mod h1:SNCIfmlU9noiVhntMcTa6Hxcr0jdINRdYi6ZDpau/oU= 25 | tractor.dev/wanix v0.0.0-20251202012200-d92345b7417e h1:KuYa4xcqcLobvLUAaK5jE5/Du6nVVtv9Ie2P54d69Ng= 26 | tractor.dev/wanix v0.0.0-20251202012200-d92345b7417e/go.mod h1:SNCIfmlU9noiVhntMcTa6Hxcr0jdINRdYi6ZDpau/oU= 27 | tractor.dev/wanix v0.0.0-20251203091228-5d5d625caf78 h1:5SqLb1rZrJZqGxt+vvAF/Bszx5fyNG+LhudIBBIb50Y= 28 | tractor.dev/wanix v0.0.0-20251203091228-5d5d625caf78/go.mod h1:SNCIfmlU9noiVhntMcTa6Hxcr0jdINRdYi6ZDpau/oU= 29 | tractor.dev/wanix v0.0.0-20251203194256-c82e6264ec6c h1:b487x3eRHp6KVm/y+bKAiUtPpDVjWL0f0U74DETuuqE= 30 | tractor.dev/wanix v0.0.0-20251203194256-c82e6264ec6c/go.mod h1:SNCIfmlU9noiVhntMcTa6Hxcr0jdINRdYi6ZDpau/oU= 31 | tractor.dev/wanix v0.0.0-20251203231535-1ea06a9be861 h1:yEpNuc4mRensVUkpT5qLRjsil5N2VwgmYY6ahqb8cpI= 32 | tractor.dev/wanix v0.0.0-20251203231535-1ea06a9be861/go.mod h1:SNCIfmlU9noiVhntMcTa6Hxcr0jdINRdYi6ZDpau/oU= 33 | -------------------------------------------------------------------------------- /system/cmd/aptn/ports.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/hex" 6 | "fmt" 7 | "net" 8 | "os" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "tractor.dev/toolkit-go/engine/cli" 14 | ) 15 | 16 | func portsCmd() *cli.Command { 17 | return &cli.Command{ 18 | Usage: "ports", 19 | Short: "monitor listening ports", 20 | Run: monitorPorts, 21 | } 22 | } 23 | 24 | func monitorPorts(ctx *cli.Context, args []string) { 25 | interval := time.Duration(1 * time.Second) 26 | knownPorts, _ := getListeningPorts() 27 | 28 | for { 29 | time.Sleep(interval) 30 | 31 | currentPorts, err := getListeningPorts() 32 | if err != nil { 33 | fmt.Printf("Error: %v\n", err) 34 | continue 35 | } 36 | 37 | // Check for new ports 38 | for port := range currentPorts { 39 | if !knownPorts[port] { 40 | url := portURL(port) 41 | if url != "" { 42 | fmt.Printf("\n=> Apptron public URL: %s\n\n", url) 43 | } 44 | knownPorts[port] = true 45 | } 46 | } 47 | 48 | // Check for closed ports 49 | for port := range knownPorts { 50 | if !currentPorts[port] { 51 | // fmt.Printf("=> Apptron port closed: %d\n", port) 52 | delete(knownPorts, port) 53 | } 54 | } 55 | } 56 | } 57 | 58 | type ListeningPort struct { 59 | Port int 60 | Address string 61 | } 62 | 63 | func getListeningPorts() (map[int]bool, error) { 64 | ports := make(map[int]bool) 65 | 66 | // Check both IPv4 and IPv6 67 | for _, file := range []string{"/proc/net/tcp"} { 68 | if err := parseNetFile(file, ports); err != nil { 69 | return nil, err 70 | } 71 | } 72 | 73 | return ports, nil 74 | } 75 | 76 | func parseNetFile(filename string, ports map[int]bool) error { 77 | file, err := os.Open(filename) 78 | if err != nil { 79 | return err 80 | } 81 | defer file.Close() 82 | 83 | scanner := bufio.NewScanner(file) 84 | scanner.Scan() // Skip header 85 | 86 | for scanner.Scan() { 87 | fields := strings.Fields(scanner.Text()) 88 | if len(fields) < 4 { 89 | continue 90 | } 91 | 92 | // State 0A = LISTEN 93 | if fields[3] == "0A" { 94 | // Parse local address 95 | parts := strings.Split(fields[1], ":") 96 | if len(parts) == 2 { 97 | // Convert hex port to decimal 98 | if port, err := strconv.ParseInt(parts[1], 16, 32); err == nil { 99 | ports[int(port)] = true 100 | } 101 | } 102 | } 103 | } 104 | 105 | return scanner.Err() 106 | } 107 | 108 | // encodeIP converts an IPv4 string (e.g. "127.0.0.1") to its "HHHHHHHH" hex format. 109 | func encodeIP(ipstr string) (string, error) { 110 | ip := net.ParseIP(ipstr) 111 | if ip == nil { 112 | return "", fmt.Errorf("invalid IP address") 113 | } 114 | ipv4 := ip.To4() 115 | if ipv4 == nil { 116 | return "", fmt.Errorf("not an IPv4 address") 117 | } 118 | return hex.EncodeToString(ipv4), nil 119 | } 120 | 121 | func portURL(port int) string { 122 | sessionIP := os.Getenv("SESSION_IP") 123 | if sessionIP == "" { 124 | return "" 125 | } 126 | user := os.Getenv("USER") 127 | if user == "" { 128 | return "" 129 | } 130 | ip, err := encodeIP(sessionIP) 131 | if err != nil { 132 | return "" 133 | } 134 | return fmt.Sprintf("https://tcp-%d-%s-%s.apptron.dev", port, ip, user) 135 | } 136 | -------------------------------------------------------------------------------- /extension/system/src/web/extension.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as vscode from 'vscode'; 3 | import { WanixBridge } from './bridge.js'; 4 | 5 | 6 | declare const navigator: unknown; 7 | 8 | export async function activate(context: vscode.ExtensionContext) { 9 | if (typeof navigator !== 'object') { // do not run under node.js 10 | console.error("not running in browser"); 11 | return; 12 | } 13 | 14 | const channel = new MessageChannel(); 15 | const bridge = new WanixBridge(channel.port2, "vm/1/fsys"); 16 | context.subscriptions.push(bridge); 17 | 18 | const port = (context as any).messagePassingProtocol; 19 | port.postMessage({type: "_port", port: channel.port1}, [channel.port1]); 20 | 21 | bridge.ready.then((wfsys) => { 22 | console.log("bridge ready"); 23 | const terminal = createTerminal(wfsys); 24 | context.subscriptions.push(terminal); 25 | terminal.show(); 26 | 27 | (async () => { 28 | const dec = new TextDecoder(); 29 | const stream = await wfsys.openReadable("#commands/data1"); 30 | for await (const chunk of stream) { 31 | const args = dec.decode(chunk).trim().split(" "); 32 | const cmd = args.shift(); 33 | vscode.commands.executeCommand(`apptron.${cmd}`, ...args); 34 | } 35 | })(); 36 | }); 37 | 38 | 39 | context.subscriptions.push(vscode.commands.registerCommand('apptron.open-preview', (filepath?: string) => { 40 | if (!filepath) { 41 | return; 42 | } 43 | vscode.commands.executeCommand('markdown.showPreview', vscode.Uri.parse(`wanix://${filepath}`)); 44 | })); 45 | 46 | context.subscriptions.push(vscode.commands.registerCommand('apptron.open-file', (filepath?: string) => { 47 | if (!filepath) { 48 | return; 49 | } 50 | vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(`wanix://${filepath}`)); 51 | })); 52 | 53 | context.subscriptions.push(vscode.commands.registerCommand('apptron.open-folder', (filepath?: string) => { 54 | if (!filepath) { 55 | return; 56 | } 57 | const folders = vscode.workspace.workspaceFolders; 58 | const insertIndex = folders ? folders.length : 0; 59 | const uri = vscode.Uri.parse(`wanix://${filepath}`); 60 | vscode.workspace.updateWorkspaceFolders( 61 | insertIndex, // insert at the end 62 | 0, // number of folders to remove 63 | { uri } 64 | ); 65 | })); 66 | 67 | 68 | console.log('Apptron system extension activated'); 69 | } 70 | 71 | function createTerminal(wx: any) { 72 | const writeEmitter = new vscode.EventEmitter(); 73 | let channel: any = undefined; 74 | const dec = new TextDecoder(); 75 | const enc = new TextEncoder(); 76 | const pty = { 77 | onDidWrite: writeEmitter.event, 78 | open: () => { 79 | (async () => { 80 | const stream = await wx.openReadable("#console/data"); 81 | for await (const chunk of stream) { 82 | writeEmitter.fire(dec.decode(chunk)); 83 | } 84 | })(); 85 | }, 86 | close: () => { 87 | // if (channel) { 88 | // channel.close(); 89 | // } 90 | }, 91 | handleInput: (data: string) => { 92 | wx.appendFile("#console/data", data); 93 | } 94 | }; 95 | return vscode.window.createTerminal({ name: `Shell`, pty }); 96 | } 97 | 98 | 99 | // @ts-ignore 100 | // polyfill for ReadableStream.prototype[Symbol.asyncIterator] on safari 101 | if (!ReadableStream.prototype[Symbol.asyncIterator]) { 102 | // @ts-ignore 103 | ReadableStream.prototype[Symbol.asyncIterator] = async function* () { 104 | const reader = this.getReader(); 105 | try { 106 | while (true) { 107 | const { done, value } = await reader.read(); 108 | if (done) return; 109 | yield value; 110 | } 111 | } finally { 112 | reader.releaseLock(); 113 | } 114 | }; 115 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apptron 2 | [![Discord](https://img.shields.io/discord/415940907729420288?label=Discord)](https://discord.gg/nQbgRjEBU4) ![GitHub Sponsors](https://img.shields.io/github/sponsors/progrium?label=Sponsors) 3 | 4 | Local-first development platform 5 | 6 | "The amount of amazing technology in this project is staggering. Seriously, star this." —[ibuildthecloud](https://x.com/ibuildthecloud/status/1996979376106492249) 7 | 8 | "WOW there's a lot of interesting stuff in there!" —[simonw](https://x.com/simonw/status/1997064403523707299) (see [full report](https://github.com/simonw/research/blob/main/apptron-analysis/README.md)) 9 | 10 | ## User Guide 11 | 12 | The "project environment" is the main object of Apptron. It is a full Linux 13 | environment running in the browser with a VSCode-based editor for you to do 14 | whatever you want with. For example, you can use it as: 15 | 16 | * a development environment and editor 17 | * a sandbox for AI and experiments 18 | * an editor to publish static sites 19 | * an embeddable software playground 20 | * a way to run and share Linux software on the web 21 | 22 | However, it is fully extendable, customizable, and self-hosted so you could even 23 | use it as the foundation for your own development platform or software system. 24 | 25 | Unlike cloud IDEs, Apptron runs entirely in the browser and does not depend on 26 | the cloud. It also only happens to be an IDE, as it is primarily an IDE for 27 | itself as a general compute environment, similar to Smalltalk. 28 | 29 | Since it is written mostly in Go, it has first-class language support for Go. 30 | However, you are encouraged to get other languages to work on it and add them as 31 | supported languages. 32 | 33 | ### Linux Environment 34 | 35 | Apptron runs Alpine Linux with a custom Linux kernel in [v86](https://github.com/copy/v86) 36 | by way of [Wanix](https://github.com/tractordev/wanix), which gives it extra 37 | capabilities such as native Wasm executable support and access to various DOM 38 | APIs through the filesystem. 39 | 40 | The v86 JIT emulator allows 32-bit x86 software to be run, which can be 41 | installed manually or through the Alpine package manager `apk`. A few packages 42 | are pre-installed including `make`, `git`, and `esbuild`. 43 | 44 | ### Persistence 45 | 46 | Apptron environments are like Docker images in that changes are not persisted 47 | unless committed or added to the environment build script. However, the project 48 | directory, home directory, and public directory are all persisted via browser 49 | storage and cloud synced. Changes outside these directories will be reset 50 | with every page load. However, you can mount more directories backed by browser 51 | storage. 52 | 53 | ### Virtual Network 54 | 55 | In order to install packages, full internet access is provided through a virtual 56 | network. You are given an IP from a virtual DHCP server on this network with 57 | every page load. This is known as your session IP. Session IPs are routable to 58 | each other, allowing communication across browser tabs and devices. 59 | 60 | If you run software that binds to a TCP port on this IP, it will get a public 61 | HTTPS endpoint. If the service is HTTP, the endpoint will proxy to it for the 62 | duration the software is running, similar to Ngrok. Non-HTTP TCP services can be 63 | used over the endpoint tunneled over WebSocket. 64 | 65 | ### Using Go 66 | 67 | Go can be installed via `apk`, but it is better to use the built-in bundle of 68 | Go 1.25 that includes a pre-compiled standard library. Go runs significantly 69 | slower in the browser, so this saves a lot of time with the first build. 70 | 71 | To mount and set up Go, run `source /etc/goprofile`. 72 | 73 | ## Developer Guide 74 | 75 | ### Prerequisites 76 | * Docker 77 | * Go 78 | * npm 79 | * wrangler 80 | 81 | ### Start Local Apptron 82 | ```sh 83 | make dev 84 | ``` 85 | -------------------------------------------------------------------------------- /extension/system/src/wanix/fs.js: -------------------------------------------------------------------------------- 1 | import * as duplex from "@progrium/duplex"; 2 | 3 | export class WanixFS { 4 | constructor(port) { 5 | const sess = new duplex.Session(new duplex.PortConn(port)); 6 | this.peer = new duplex.Peer(sess, new duplex.CBORCodec()); 7 | } 8 | 9 | async readDir(name) { 10 | return (await this.peer.call("ReadDir", [name])).value; 11 | } 12 | 13 | async makeDir(name) { 14 | await this.peer.call("Mkdir", [name]); 15 | } 16 | 17 | async makeDirAll(name) { 18 | await this.peer.call("MkdirAll", [name]); 19 | } 20 | 21 | async bind(name, newname) { 22 | await this.peer.call("Bind", [name, newname]); 23 | } 24 | 25 | async unbind(name, newname) { 26 | await this.peer.call("Unbind", [name, newname]); 27 | } 28 | 29 | async readFile(name) { 30 | return (await this.peer.call("ReadFile", [name])).value; 31 | } 32 | 33 | async readText(name) { 34 | return (new TextDecoder()).decode(await this.readFile(name)); 35 | } 36 | 37 | async waitFor(name, timeoutMs=1000) { 38 | await this.peer.call("WaitFor", [name, timeoutMs]); 39 | } 40 | 41 | async stat(name) { 42 | return (await this.peer.call("Stat", [name])).value; 43 | } 44 | 45 | async writeFile(name, contents) { 46 | if (typeof contents === "string") { 47 | contents = (new TextEncoder()).encode(contents); 48 | } 49 | return (await this.peer.call("WriteFile", [name, contents])).value; 50 | } 51 | 52 | async appendFile(name, contents) { 53 | if (typeof contents === "string") { 54 | contents = (new TextEncoder()).encode(contents); 55 | } 56 | return (await this.peer.call("AppendFile", [name, contents])).value; 57 | } 58 | 59 | async rename(oldname, newname) { 60 | await this.peer.call("Rename", [oldname, newname]); 61 | } 62 | 63 | async copy(oldname, newname) { 64 | await this.peer.call("Copy", [oldname, newname]); 65 | } 66 | 67 | async remove(name) { 68 | await this.peer.call("Remove", [name]); 69 | } 70 | 71 | async removeAll(name) { 72 | await this.peer.call("RemoveAll", [name]); 73 | } 74 | 75 | async truncate(name, size) { 76 | await this.peer.call("Truncate", [name, size]); 77 | } 78 | 79 | async open(name) { 80 | return (await this.peer.call("Open", [name])).value; 81 | } 82 | 83 | async read(fd, count) { 84 | return (await this.peer.call("Read", [fd, count])).value; 85 | } 86 | 87 | async write(fd, data) { 88 | return (await this.peer.call("Write", [fd, data])).value; 89 | } 90 | 91 | async close(fd) { 92 | return (await this.peer.call("Close", [fd])).value; 93 | } 94 | 95 | async sync(fd) { 96 | return (await this.peer.call("Sync", [fd])).value; 97 | } 98 | 99 | async openReadable(name) { 100 | const fd = await this.open(name); 101 | return this.readable(fd); 102 | } 103 | 104 | async openWritable(name) { 105 | const fd = await this.open(name); 106 | return this.writable(fd); 107 | } 108 | 109 | writable(fd) { 110 | const self = this; 111 | return new WritableStream({ 112 | write(chunk) { 113 | return self.write(fd, chunk); 114 | }, 115 | }); 116 | } 117 | 118 | readable(fd) { 119 | const self = this; 120 | return new ReadableStream({ 121 | async pull(controller) { 122 | const data = await self.read(fd, 1024); 123 | if (data === null) { 124 | controller.close(); 125 | } 126 | controller.enqueue(data); 127 | }, 128 | }); 129 | } 130 | } -------------------------------------------------------------------------------- /worker/src/util.ts: -------------------------------------------------------------------------------- 1 | import { HOST_DOMAIN } from "./config"; 2 | import { handle as handleR2FS } from "./r2fs"; 3 | 4 | export function isLocal(env: any) { 5 | return !!(env && env.LOCALHOST); 6 | } 7 | 8 | export function redirectToSignin(env: any, url: URL) { 9 | if (isLocal(env)) { 10 | url.host = env.LOCALHOST; 11 | } else { 12 | url.host = HOST_DOMAIN; 13 | } 14 | url.pathname = "/signin"; 15 | return Response.redirect(url.toString(), 307); 16 | } 17 | 18 | export function insertMeta(resp: Response, meta: Record) { 19 | return new HTMLRewriter().on('head', { 20 | element(element) { 21 | for (const [name, content] of Object.entries(meta)) { 22 | element.append(``, { html: true }); 23 | } 24 | } 25 | }).transform(resp); 26 | } 27 | 28 | export function insertHTML(resp: Response, element: string, content: string) { 29 | return new HTMLRewriter().on(element, { 30 | element(element) { 31 | element.append(content, { html: true }); 32 | } 33 | }).transform(resp); 34 | } 35 | 36 | export function uuidv4() { 37 | // Generate a RFC4122 version 4 UUID string. 38 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 39 | const r = crypto.getRandomValues(new Uint8Array(1))[0] & 15; 40 | const v = c === 'x' ? r : (r & 0x3 | 0x8); 41 | return v.toString(16); 42 | }); 43 | } 44 | 45 | export function cleanpath(path: string): string { 46 | if (!path.startsWith("/")) { 47 | path = "/" + path; 48 | } 49 | if (path.length > 1 && path.endsWith("/")) { 50 | path = path.slice(0, -1); 51 | } 52 | return path; 53 | } 54 | 55 | export async function checkpath(req: Request, env: any, path: string): Promise { 56 | path = cleanpath(path); 57 | const url = new URL(req.url); 58 | url.host = (isLocal(env) ? env.LOCALHOST : HOST_DOMAIN); 59 | url.pathname = `/data${path}`; 60 | const checkReq = new Request(url.toString(), {method: "HEAD"}); 61 | return handleR2FS(checkReq, env, "/data"); 62 | } 63 | 64 | export async function copypath(req: Request, env: any, src: string, dst: string): Promise { 65 | // Ensure paths start with a "/" and does not end with one (unless path is just "/") 66 | src = cleanpath(src); 67 | dst = cleanpath(dst); 68 | 69 | const url = new URL(req.url); 70 | url.host = (isLocal(env) ? env.LOCALHOST : HOST_DOMAIN); 71 | url.pathname = `/data${src}`; 72 | const copyReq = new Request(url.toString(), { 73 | method: "COPY", 74 | headers: { 75 | "Destination": `/data${dst}` 76 | } 77 | }); 78 | return handleR2FS(copyReq, env, "/data"); 79 | } 80 | 81 | export async function deletepath(req: Request, env: any, path: string): Promise { 82 | // Ensure path starts with a "/" and does not end with one (unless path is just "/") 83 | path = cleanpath(path); 84 | const url = new URL(req.url); 85 | url.host = (isLocal(env) ? env.LOCALHOST : HOST_DOMAIN); 86 | url.pathname = `/data${path}/`; 87 | const delReq = new Request(url.toString(), {method: "DELETE"}); 88 | return handleR2FS(delReq, env, "/data"); 89 | } 90 | 91 | export async function putdir(req: Request, env: any, path: string, attrs?: Record): Promise { 92 | // Ensure path starts with a "/" and does not end with one (unless path is just "/") 93 | path = cleanpath(path); 94 | const url = new URL(req.url); 95 | url.host = (isLocal(env) ? env.LOCALHOST : HOST_DOMAIN); 96 | url.pathname = `/data${path}/`; 97 | const headers = { 98 | "Content-Type": "application/x-directory", 99 | "Change-Timestamp": (Date.now() * 1000).toString(), 100 | } 101 | if (attrs) { 102 | for (const [key, value] of Object.entries(attrs)) { 103 | headers[`Attribute-${key}`] = value; 104 | } 105 | } 106 | const putReq = new Request(url.toString(), {method: "PUT", headers}); 107 | return handleR2FS(putReq, env, "/data"); 108 | } -------------------------------------------------------------------------------- /assets/_env.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Apptron 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 39 | 40 | 41 | 42 | 43 | 131 |
132 | 133 |
134 | 136 | 137 | 138 |
139 |

Loading...

140 |
141 | 142 | -------------------------------------------------------------------------------- /worker/go.sum: -------------------------------------------------------------------------------- 1 | github.com/apparentlymart/go-cidr v1.1.0 h1:2mAhrMoF+nhXqxTzSZMUzDHkLjmIHC+Zzn4tdgBZjnU= 2 | github.com/apparentlymart/go-cidr v1.1.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= 3 | github.com/armon/go-proxyproto v0.0.0-20210323213023-7e956b284f0a/go.mod h1:QmP9hvJ91BbJmGVGSbutW19IC0Q9phDCLGaomwTJbgU= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= 8 | github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 9 | github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 10 | github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 11 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 12 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 13 | github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c h1:gYfYE403/nlrGNYj6BEOs9ucLCAGB9gstlSk92DttTg= 14 | github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c/go.mod h1:Di7LXRyUcnvAcLicFhtM9/MlZl/TNgRSDHORM2c6CMI= 15 | github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= 16 | github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= 17 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 18 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 | github.com/progrium/go-netstack v0.0.0-20240720002214-37b2b8227b91 h1:t3b5g0NdnPz4KlTgFPCxOFfG0qeNmgXDMzEr8j31Rzc= 22 | github.com/progrium/go-netstack v0.0.0-20240720002214-37b2b8227b91/go.mod h1:IWGVCFj8gqgUlsjm+dEKWNsDcBp0gqplYPLz6BdrJQ8= 23 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 24 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 25 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 26 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 27 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 28 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 29 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 30 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 31 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 32 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 33 | golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= 34 | golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 35 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 36 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 37 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 38 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 39 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 40 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 41 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 42 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 43 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 46 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 47 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 48 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 49 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 50 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 51 | golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= 52 | golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= 53 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 54 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 55 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 56 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 57 | -------------------------------------------------------------------------------- /system/cmd/aptn/exec.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | "sync/atomic" 16 | "time" 17 | 18 | "tractor.dev/toolkit-go/engine/cli" 19 | ) 20 | 21 | func execCmd() *cli.Command { 22 | return &cli.Command{ 23 | Usage: "exec [args...]", 24 | Short: "execute a WASM binary", 25 | Run: execWasm, 26 | } 27 | } 28 | 29 | func debug(format string, a ...any) { 30 | if os.Getenv("DEBUG") == "1" { 31 | log.Printf(format+"\n", a...) 32 | } 33 | } 34 | 35 | func execWasm(ctx *cli.Context, args []string) { 36 | taskType, err := detectWASMType(args[0]) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | debug("detected WASM type: %s", taskType) 41 | 42 | wd, err := os.Getwd() 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | wasmArgs := args 47 | absArg0, err := filepath.Abs(args[0]) 48 | if err != nil { 49 | log.Fatal(err) 50 | } 51 | // ultimately we shouldn't need to prefix the path with vm/1/fsys, 52 | // it should be relative to the task namespace 53 | wasmArgs[0] = strings.TrimPrefix(filepath.Join("vm/1/fsys", absArg0), "/") 54 | 55 | debug("allocating pid") 56 | pidRaw, err := os.ReadFile(fmt.Sprintf("/task/new/%s", taskType)) 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | pid := strings.TrimSpace(string(pidRaw)) 61 | 62 | debug("writing cmd") 63 | if err := appendFile(fmt.Sprintf("/task/%s/cmd", pid), []byte(strings.Join(wasmArgs, " "))); err != nil { 64 | log.Fatal(err) 65 | } 66 | 67 | debug("writing dir") 68 | if err := appendFile(fmt.Sprintf("/task/%s/dir", pid), []byte(wd)); err != nil { 69 | log.Fatal(err) 70 | } 71 | 72 | debug("writing env") 73 | env := strings.Join(append(os.Environ(), ""), "\n") 74 | if err := appendFile(fmt.Sprintf("/task/%s/env", pid), []byte(env)); err != nil { 75 | log.Fatal(err) 76 | } 77 | 78 | var done atomic.Int32 79 | var wg sync.WaitGroup 80 | wg.Add(1) 81 | go func() { 82 | defer wg.Done() 83 | debug("polling fd/1 => stdout") 84 | f, err := os.Open(fmt.Sprintf("/task/%s/fd/1", pid)) 85 | if err != nil { 86 | log.Fatal(err) 87 | } 88 | defer f.Close() 89 | b := make([]byte, 4096) 90 | for { 91 | n, err := f.Read(b) 92 | if err != nil && err != io.EOF { 93 | log.Fatal(err) 94 | } 95 | if done.Load() == 1 && n == 0 { 96 | debug("stdout thread done") 97 | return 98 | } 99 | os.Stdout.Write(b[:n]) 100 | time.Sleep(30 * time.Millisecond) 101 | } 102 | }() 103 | wg.Add(1) 104 | go func() { 105 | defer wg.Done() 106 | debug("polling fd/2 => stderr") 107 | f, err := os.Open(fmt.Sprintf("/task/%s/fd/2", pid)) 108 | if err != nil { 109 | log.Fatal(err) 110 | } 111 | defer f.Close() 112 | b := make([]byte, 4096) 113 | for { 114 | n, err := f.Read(b) 115 | if err != nil && err != io.EOF { 116 | log.Fatal(err) 117 | } 118 | if done.Load() == 1 && n == 0 { 119 | debug("stderr thread done") 120 | return 121 | } 122 | os.Stderr.Write(b[:n]) 123 | time.Sleep(30 * time.Millisecond) 124 | } 125 | }() 126 | 127 | debug("starting") 128 | if err := appendFile(fmt.Sprintf("/task/%s/ctl", pid), []byte("start")); err != nil { 129 | log.Fatal(err) 130 | } 131 | 132 | debug("waiting for exit") 133 | for { 134 | b, err := os.ReadFile(fmt.Sprintf("/task/%s/exit", pid)) 135 | if err != nil { 136 | log.Fatal(err) 137 | } 138 | out := strings.TrimSpace(string(b)) 139 | if out != "" { 140 | debug("exit code: %s", out) 141 | code, err := strconv.Atoi(out) 142 | if err != nil { 143 | log.Fatal(err) 144 | } 145 | done.Store(1) 146 | debug("waiting for threads to finish") 147 | wg.Wait() 148 | debug("exiting with code %d", code) 149 | os.Exit(code) 150 | } 151 | time.Sleep(100 * time.Millisecond) 152 | } 153 | } 154 | 155 | func appendFile(path string, data []byte) error { 156 | f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) 157 | if err != nil { 158 | return err 159 | } 160 | defer f.Close() 161 | _, err = f.Write(data) 162 | return err 163 | } 164 | 165 | func detectWASMType(path string) (string, error) { 166 | f, err := os.Open(path) 167 | if err != nil { 168 | return "", err 169 | } 170 | defer f.Close() 171 | 172 | // Skip WASM header (8 bytes: magic + version) 173 | f.Seek(8, 0) 174 | 175 | // Read sections until we find imports (section ID 2) 176 | for { 177 | var sectionID byte 178 | if err := binary.Read(f, binary.LittleEndian, §ionID); err != nil { 179 | return "", err 180 | } 181 | 182 | size := readVarUint(f) 183 | 184 | if sectionID == 2 { // Import section 185 | buf := make([]byte, size) 186 | f.Read(buf) 187 | 188 | if bytes.Contains(buf, []byte("wasi_snapshot_preview1")) { 189 | return "wasi", nil 190 | } 191 | if bytes.Contains(buf, []byte("gojs")) { 192 | return "gojs", nil 193 | } 194 | return "", errors.New("unknown WASM type") 195 | } 196 | 197 | f.Seek(int64(size), io.SeekCurrent) 198 | } 199 | } 200 | 201 | func readVarUint(r io.Reader) uint64 { 202 | var v uint64 203 | var s uint 204 | b := []byte{0} 205 | for { 206 | r.Read(b) 207 | v |= uint64(b[0]&0x7f) << s 208 | if b[0]&0x80 == 0 { 209 | break 210 | } 211 | s += 7 212 | } 213 | return v 214 | } 215 | -------------------------------------------------------------------------------- /assets/com/project.html: -------------------------------------------------------------------------------- 1 | 2 | 9 | 85 |
86 |
87 |

New Project

88 | 89 |
90 | 91 |
92 | 93 | 96 | 97 | 98 | 99 | 100 | 101 |
102 | Visibility 103 | 104 | 111 | 119 |
120 |
121 | 122 | 126 |
127 | 128 | -------------------------------------------------------------------------------- /assets/com/share_share.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 86 |
87 | 88 |
89 | Visibility 90 | 91 | 99 | 100 | 108 |
109 | 110 |
111 | 112 |
113 | 114 | 120 |
121 | 122 |
123 | 124 | 127 | 128 |
129 | -------------------------------------------------------------------------------- /assets/com/share_publish.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 100 | 101 |
102 |
103 |

Publish syncs the source path of your project to the public mount, which is served at the Public URL.

104 | 105 | 106 | 107 | 108 | 109 |
110 | 111 | 117 |
118 |
119 | 120 | 123 |
124 | 125 | 126 | -------------------------------------------------------------------------------- /assets/lib/html-component.js: -------------------------------------------------------------------------------- 1 | export class HTMLComponent extends HTMLElement { 2 | constructor() { 3 | super(); 4 | this.attachShadow({ mode: 'open' }); 5 | this._scriptContexts = new Map(); 6 | } 7 | 8 | static get observedAttributes() { 9 | return ['src']; 10 | } 11 | 12 | connectedCallback() { 13 | const src = this.getAttribute('src'); 14 | if (src) { 15 | this.load(src); 16 | } 17 | } 18 | 19 | async load(url) { 20 | try { 21 | const response = await fetch(url); 22 | const html = await response.text(); 23 | 24 | // Parse and process the template 25 | const parser = new DOMParser(); 26 | const doc = parser.parseFromString(html, 'text/html'); 27 | const com = doc.querySelector('html'); 28 | 29 | if (!com) { 30 | throw new Error('No component found'); 31 | } 32 | 33 | // Create a document fragment from template content 34 | const fragment = document.createDocumentFragment(); 35 | const tempContainer = document.createElement('div'); 36 | tempContainer.innerHTML = com.innerHTML; 37 | 38 | // Process each node 39 | this.processNodes(tempContainer, fragment); 40 | 41 | // Clear and append to shadow root 42 | this.shadowRoot.innerHTML = ''; 43 | this.shadowRoot.appendChild(fragment); 44 | 45 | // Initialize any scripts after DOM is ready 46 | await this.initializeScripts(); 47 | 48 | // Fire loaded event 49 | this.dispatchEvent(new CustomEvent('loaded', { 50 | detail: { url }, 51 | bubbles: true 52 | })); 53 | 54 | } catch (error) { 55 | this.handleError(error); 56 | } 57 | } 58 | 59 | processNodes(source, target) { 60 | Array.from(source.childNodes).forEach(node => { 61 | if (node.nodeType === Node.ELEMENT_NODE) { 62 | if (node.tagName === 'SCRIPT') { 63 | // Store script for later execution 64 | this._scriptContexts.set(node, node.textContent || node.src); 65 | } else { 66 | // Clone and append other elements 67 | const cloned = node.cloneNode(true); 68 | target.appendChild(cloned); 69 | } 70 | } else { 71 | // Clone text nodes and comments 72 | target.appendChild(node.cloneNode(true)); 73 | } 74 | }); 75 | } 76 | 77 | async initializeScripts() { 78 | for (const [scriptNode, content] of this._scriptContexts) { 79 | if (scriptNode.src) { 80 | // Load external script 81 | const script = document.createElement('script'); 82 | script.src = scriptNode.src; 83 | script.type = scriptNode.type || 'text/javascript'; 84 | this.shadowRoot.appendChild(script); 85 | } else { 86 | // Execute inline script with shadow DOM context (supports import and await) 87 | await this.executeInlineScript(content); 88 | } 89 | } 90 | 91 | // Clear stored scripts 92 | this._scriptContexts.clear(); 93 | } 94 | 95 | async executeInlineScript(code) { 96 | try { 97 | // Create a unique context ID for this script execution 98 | const contextId = `__htmlComponent_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 99 | 100 | // Store context in a temporary global variable 101 | window[contextId] = { 102 | shadowRoot: this.shadowRoot, 103 | component: this, 104 | $: (selector) => this.shadowRoot.querySelector(selector), 105 | $$: (selector) => this.shadowRoot.querySelectorAll(selector) 106 | }; 107 | 108 | // Resolve relative imports to absolute URLs 109 | // This is necessary because blob URLs don't have a proper base for resolution 110 | const resolvedCode = this.resolveImports(code); 111 | 112 | // Create a module script that retrieves the context 113 | // This allows use of import and await 114 | const moduleCode = ` 115 | const { shadowRoot, component, $, $$ } = window['${contextId}']; 116 | 117 | // Clean up the global context immediately after retrieval 118 | delete window['${contextId}']; 119 | 120 | // Execute the original script (with import and await support) 121 | ${resolvedCode} 122 | `; 123 | 124 | // Create a blob URL for the module 125 | const blob = new Blob([moduleCode], { type: 'text/javascript' }); 126 | const url = URL.createObjectURL(blob); 127 | 128 | try { 129 | // Import and execute as a module 130 | await import(url); 131 | } finally { 132 | // Clean up the blob URL 133 | URL.revokeObjectURL(url); 134 | // Ensure context is cleaned up even if script fails 135 | delete window[contextId]; 136 | } 137 | 138 | } catch (error) { 139 | console.error('Script execution error:', error); 140 | } 141 | } 142 | 143 | resolveImports(code) { 144 | // Replace import statements with absolute URLs 145 | // Matches: import ... from "path" or import ... from 'path' 146 | return code.replace( 147 | /import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)(?:\s*,\s*(?:\{[^}]*\}|\*\s+as\s+\w+|\w+))*\s+from\s+)?['"]([^'"]+)['"]/g, 148 | (match, path) => { 149 | // Convert relative paths to absolute URLs 150 | const absoluteUrl = new URL(path, window.location.origin + window.location.pathname).href; 151 | return match.replace(path, absoluteUrl); 152 | } 153 | ); 154 | } 155 | 156 | handleError(error) { 157 | console.error('Component loading error:', error); 158 | this.shadowRoot.innerHTML = ` 159 |
160 | Error: ${error.message} 161 |
162 | `; 163 | 164 | this.dispatchEvent(new CustomEvent('error', { 165 | detail: { error }, 166 | bubbles: true 167 | })); 168 | } 169 | 170 | // Utility methods for external access 171 | getElement(selector) { 172 | return this.shadowRoot.querySelector(selector); 173 | } 174 | 175 | getElements(selector) { 176 | return this.shadowRoot.querySelectorAll(selector); 177 | } 178 | } 179 | 180 | customElements.define('html-component', HTMLComponent); -------------------------------------------------------------------------------- /extension/preview/README.md: -------------------------------------------------------------------------------- 1 | # Browser Preview Extension 2 | 3 | A VSCode extension that provides a webview-based browser panel with navigation controls, designed to be consistent with VSCode themes. Opens as a full editor tab rather than a sidebar. 4 | 5 | ## Features 6 | 7 | - **Editor-based Browser Panel**: Opens as a full editor tab for maximum screen real estate 8 | - **Iframe-based Browser**: Uses iframe for web content rendering to bypass VSCode webview restrictions 9 | - **Navigation Controls**: Back, forward, and reload buttons with full history management 10 | - **Iframe Navigation Tracking**: Automatically tracks when users click links within the iframe and updates history 11 | - **URL Bar**: Enter URLs or search terms directly 12 | - **Zoom Controls**: Zoom in/out and reset zoom for better content viewing 13 | - **Theme Integration**: Styled to match VSCode's current theme using CSS variables 14 | - **Context Menu Support**: Right-click on selected text to open URLs in the browser preview 15 | - **Smart URL Handling**: 16 | - Automatically adds `https://` protocol if missing 17 | - Treats non-URL text as Google search queries 18 | - Validates URLs before navigation 19 | - **Panel Persistence**: Maintains state when VSCode is restarted 20 | 21 | ## Installation 22 | 23 | 1. Build the extension: 24 | ```bash 25 | npm install 26 | npm run compile-web 27 | ``` 28 | 29 | 2. The extension will be compiled to `dist/web/extension.js` 30 | 31 | ## Usage 32 | 33 | ### Opening the Browser Preview 34 | 35 | 1. **Command Palette**: Press `Cmd+Shift+P` (macOS) or `Ctrl+Shift+P` (Windows/Linux) and type "Open Browser Preview" 36 | 2. **Context Menu**: Select a URL in any file, right-click, and choose "Open in Browser Preview" 37 | 38 | The browser preview will open as a new editor tab alongside your code. 39 | 40 | ### Navigation 41 | 42 | - **URL Bar**: Type any URL or search term and press Enter 43 | - **Back/Forward**: Use the `‹` and `›` buttons to navigate through history 44 | - **Reload**: Use the `⟳` button to refresh the current page 45 | - **Sync URL**: Use the `⟲` button to manually sync the URL bar with the current iframe location 46 | - **Zoom Controls**: Use `−` and `+` buttons to zoom out/in, or `⚏` to reset zoom to 100% 47 | - **Link Tracking**: Automatic for same-origin sites, manual sync available for cross-origin sites 48 | 49 | ### Smart URL Handling 50 | 51 | The extension intelligently handles different types of input: 52 | 53 | - **Full URLs**: `https://example.com` → Opens directly 54 | - **Domain names**: `example.com` → Automatically adds `https://` 55 | - **Search queries**: `javascript tutorial` → Searches on Google 56 | - **Selected text**: Highlight any text and use the context menu to open it 57 | 58 | ## Architecture 59 | 60 | The extension uses: 61 | 62 | - **WebView Panel API**: Creates a custom webview panel in VSCode that opens as an editor tab 63 | - **Iframe**: Embeds web content using iframe to bypass security restrictions 64 | - **Message Passing**: Communication between extension and webview via postMessage 65 | - **History Management**: Tracks navigation history for back/forward functionality, including iframe navigation 66 | - **Zoom Transformation**: CSS transforms to scale iframe content 67 | - **Navigation Monitoring**: Periodic checking and event listening for iframe URL changes 68 | - **CSS Variables**: Uses VSCode theme variables for consistent styling 69 | - **Panel Persistence**: Automatically restores browser panels when VSCode restarts 70 | 71 | ## Files 72 | 73 | - `src/web/extension.ts` - Main extension logic and webview provider 74 | - `package.json` - Extension manifest with commands and contributions 75 | - `dist/web/extension.js` - Compiled extension bundle 76 | 77 | ## Commands 78 | 79 | - `preview.openBrowser` - Opens the browser preview panel 80 | - `preview.openUrlInBrowser` - Opens selected text as URL in browser preview 81 | 82 | ## Theme Integration 83 | 84 | The extension automatically adapts to your current VSCode theme using CSS variables: 85 | 86 | - `--vscode-button-background` 87 | - `--vscode-button-foreground` 88 | - `--vscode-input-background` 89 | - `--vscode-input-foreground` 90 | - `--vscode-focusBorder` 91 | - And more... 92 | 93 | ## Development 94 | 95 | To modify the extension: 96 | 97 | 1. Edit `src/web/extension.ts` 98 | 2. Run `npm run compile-web` to rebuild 99 | 3. Reload the VSCode window to test changes 100 | 101 | ## Inspiration 102 | 103 | This extension takes inspiration from the browser preview functionality in the [vscode-livepreview](https://github.com/microsoft/vscode-livepreview) extension, focusing specifically on the browser panel component with enhanced navigation controls and theme integration. 104 | 105 | ## Browser Compatibility 106 | 107 | The iframe-based approach works with most websites, though some sites may have security policies that prevent embedding. This is a limitation of iframe technology, not the extension itself. 108 | 109 | **Navigation Tracking Limitations**: 110 | 111 | Due to browser security restrictions, navigation tracking has different capabilities depending on the website: 112 | 113 | - **Same-Origin Sites** (localhost, file://, etc.): Full automatic navigation tracking works perfectly 114 | - **Cross-Origin Sites** (most external websites): Cannot automatically detect navigation due to browser security policies 115 | - **Manual Sync**: Use the `⟲` sync button to manually update the URL bar with the current page location 116 | - **Workaround**: For cross-origin sites, you can manually edit the URL bar or use the sync button after navigating 117 | 118 | ## Key Improvements 119 | 120 | ### Editor vs Sidebar 121 | - **Full Screen Experience**: Opens as an editor tab, giving you maximum screen real estate for browsing 122 | - **Better Integration**: Behaves like any other VSCode editor tab with proper focus management 123 | 124 | ### Enhanced Navigation 125 | - **Link Click Tracking**: Automatically detects when you click links in the iframe and updates the browser controls 126 | - **Comprehensive History**: Maintains full browsing history including iframe navigation 127 | - **Smart URL Updates**: URL bar automatically updates as you navigate 128 | 129 | ### Zoom Features 130 | - **Flexible Zooming**: Zoom range from 50% to 300% with 10% increments 131 | - **Visual Feedback**: Real-time zoom percentage display 132 | - **Reset Functionality**: Quick reset to 100% zoom -------------------------------------------------------------------------------- /assets/_vscode.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 35 | 36 | 37 | 38 | 41 | 42 | 43 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /system/apptron/WELCOME.md: -------------------------------------------------------------------------------- 1 | # Welcome to Apptron 2 | 3 | Thanks for using Apptron, a full blown Linux based developer environment that runs entirely in your web browser. 4 | 5 | There is a lot you can do with this environment. If this is your first time using Apptron, this document shows a few features for you to try. 6 | 7 | If you close this document, you can bring it back by running this in the terminal: 8 | 9 | ``` 10 | open /apptron/WELCOME.md 11 | ``` 12 | 13 | ## Install a package 14 | 15 | Apptron is based on Alpine Linux, so you can install any package in the [Alpine package repository](https://pkgs.alpinelinux.org/packages). Try running this command: 16 | 17 | ``` 18 | apk add -u sl 19 | ``` 20 | 21 | This will install the latest in reverse "ls" technology. Once the package is installed try running it! 22 | 23 | ``` 24 | sl 25 | ``` 26 | 27 | Fun for the whole family! 28 | 29 | ## Publish an HTML file 30 | 31 | Among other things, Apptron might just be the quickest way to publish websites: 32 | 33 | - In the sidebar, click the file icon with the plus symbol for "New File" 34 | - Name the new file `index.html` 35 | - Copy the code below into `index.html` 36 | 37 | ```html 38 | 39 | Apptron Demo 40 | 41 |

Hello world.

42 |
I am an HTML file.
43 | 44 | 45 | ``` 46 | 47 | - Save the file using Ctrl+S, or Command+S on macOS 48 | - In the upper right, click the "Share" button 49 | - Select the "Publish" tab 50 | - Leave the *Source Path* as `.` for the project root 51 | - Copy the *Public URL* into your clipboard 52 | - Click the "Publish" button 53 | 54 | To view the page you just published: 55 | 56 | - Open a new tab 57 | - Paste the URL from your clipboard into the tab location bar 58 | - Press the "Return" key to visit that page 59 | 60 | Congrats! You successfully published a web page with Apptron. 61 | 62 | > NOTE: If you get a 404, it might be because you loaded the URL too soon and now have a negative cache entry for that page. Clear that with a hard reload, holding Shift while reloading. 63 | 64 | ## Advanced Publishing 65 | 66 | The "Share > Publish" UI is a convenience workflow for copying files to the system's `/public` mount. Any files there will be published to the URL that you used above. 67 | 68 | Try it out by doing the following in your Terminal: 69 | 70 | ``` 71 | echo "Hello from the command-line" > /public/hello.txt 72 | ``` 73 | 74 | Now add `/hello.txt` to the end of the URL from earlier and load it, you should see the file you just created. 75 | 76 | To sync a whole directory to `/public` from the command-line, you can use the `publish` command. Running this in the project root is the same as using the Publish UI using `.` as the source: 77 | 78 | ``` 79 | publish . 80 | ``` 81 | 82 | ## Write and build a Go program 83 | 84 | While writing and running JavaScript code in the browser is quite common, Apptron is the first environment that lets you write and compile the Go systems language entirely in-browser. 85 | 86 | Start with loading the Go profile by running this command in the terminal: 87 | 88 | ``` 89 | source /etc/goprofile 90 | ``` 91 | 92 | Now you should have the `go` toolchain. You can see the version with: 93 | 94 | ``` 95 | go version 96 | ``` 97 | 98 | Use the "New File" button to create a file named `hello.go` and then paste this code into it: 99 | 100 | ```go 101 | package main 102 | 103 | import ( 104 | "fmt" 105 | "os" 106 | ) 107 | 108 | func main() { 109 | if len(os.Args) < 2 { 110 | fmt.Println("Usage: hello ") 111 | os.Exit(1) 112 | } 113 | fmt.Printf("Hello, %s!\n", os.Args[1]) 114 | } 115 | ``` 116 | 117 | Then build the file we just created by running: 118 | 119 | ``` 120 | time go build -o hello hello.go 121 | ``` 122 | 123 | Patiently wait while the Go compiler runs in your browser. 124 | 125 | > On an M3 Macbook, this takes about 10 seconds to run the first time. Let us know how long it takes on your system: Copy and paste the results into the Feedback dialog, which you can get by clicking the icon to right of the GitHub and Discord icons in the top bar. 126 | 127 | This will create a binary named `hello` that accepts a name as a parameter, run it by typing this command: 128 | 129 | ``` 130 | ./hello Apptron 131 | ``` 132 | 133 | You should see this: 134 | 135 | ``` 136 | Hello, Apptron! 137 | ``` 138 | 139 | Go lets you cross compile to any platform/architecture. See if you can figure out how to compile a version of the program that will run natively on your platform if you download it. 140 | 141 | Go is the first of many languages Apptron will support. If you have a preference for what we support next, cast your vote in this [GitHub Discussion thread](https://github.com/tractordev/apptron/discussions/215). 142 | 143 | ## Run Apache from inside your browser 144 | 145 | We start by installing Apache: 146 | 147 | ``` 148 | apk add -u apache2 149 | ``` 150 | 151 | Once that is done, run Apache directly in the foreground by running this command: 152 | 153 | ``` 154 | httpd -DFOREGROUND 155 | ``` 156 | 157 | After Apache starts with some non-critical warnings, Apptron will detect it listening on port 80 and give you a public URL to visit. 158 | 159 | It should look like this: 160 | 161 | ``` 162 | AH00557: httpd: apr_sockaddr_info_get() failed for (none) 163 | AH00558: httpd: Could not reliably determine the server's fully qualified domain name, using 127.0.0.1. Set the 'ServerName' directive globally to suppress this message 164 | 165 | => Apptron public URL: https://tcp-80-0a00001e-example.apptron.dev 166 | 167 | ``` 168 | 169 | Once you see the Apptron public URL, click or copy-paste it to see Apache running! 170 | 171 | This URL is sharable and will work for anyone on the internet as long as your browser tab is running. 172 | 173 | What other servers can you run in the browser with Apptron? 174 | 175 | ## More tips for this environment 176 | 177 | * There are 3 persistent mounts in an Apptron environment: 178 | * `/project` - Files shown in the sidebar for this environment 179 | * `/home/$USER` - Your home directory available in all environments 180 | * `/public` - The website for this environment 181 | * All other files, including installed programs, will be reset with every pageload / session. 182 | * An `.apptron` directory in the project root lets you customize the environment with these files: 183 | * `.apptron/envrc` - Commands here will be run at the start of every session 184 | * `.apptron/envbuild` - Commands here will be used to rebuild the environment. (experimental) 185 | * Files can be uploaded into the project mount by dragging them into the sidebar 186 | * You can use the `open` command to open more files or folders into the editor UI 187 | 188 | ## Give us feedback 189 | 190 | You can easily send us a message by clicking the Feedback icon to the right of the GitHub and Discord icons next to the Apptron logo in the top bar. We'd love to hear from you! 191 | 192 | Even better if you [join our Discord](https://discord.gg/zCrpdAgZAf) or [star the project on GitHub](https://github.com/tractordev/apptron). :) 193 | 194 | Enjoy!
195 | —Apptron team -------------------------------------------------------------------------------- /assets/sw.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file provides a generic message-based service worker infrastructure for custom HTTP-like request/response 3 | * handling between the main thread (web clients) and a service worker. 4 | * 5 | * STILL IN DEVELOPMENT, DO NOT USE IN PRODUCTION 6 | * 7 | * Key Features: 8 | * -------------- 9 | * - Instantly claims clients and skips waiting (`install` and `activate` logic). 10 | * - Maintains a per-client registration so the SW can coordinate with responder endpoints in each client. 11 | * - For requests with paths beginning with `/:/`, the SW: 12 | * - Finds the currently "registered" responder for the client. 13 | * - Forwards the fetch metadata (method, URL, headers) to the registered responder via a MessageChannel. 14 | * - Waits for a reply or timeout, then returns the reply (with headers/body/status) as a `Response` object. 15 | * - On error or timeout returns a network error response. 16 | * - The `register(handler)` function is designed to be called on the client side: 17 | * - It sets up a local responder that can handle SW requests by running a user-defined `handler(Request)`. 18 | * - It registers the responder with the SW via `postMessage`, and handles communication using MessageChannel ports. 19 | * - Ensures pages can drive fetch logic from the "application" side with a handler for `/ : /` requests. 20 | * 21 | * Intended Usage: 22 | * --------------- 23 | * 1. On the main thread, call `register(handler)` to provide a custom request handler for SW requests. 24 | * 2. Any fetch from the page with a path like `/:/something` will go through the SW, which relays the request 25 | * via messages to the registered handler, receives the result, and responds to the fetch. 26 | * 3. Supports advanced scenarios like mocking server APIs, on-the-fly content, offline logic, etc. 27 | * 28 | * Notes: 29 | * ------ 30 | * - This system is not for general HTTP request interception, only requests matching specific path prefixes. 31 | * - All communication uses MessageChannel ports for structured, race-condition-safe messaging. 32 | * - Timeout, error, and registration logic are handled to ensure robust messaging regardless of page state. 33 | * - The code is ES module-compatible. 34 | * 35 | * Example (client page): 36 | * ---------------------- 37 | * import { register } from '/sw.js'; 38 | * await register(async (req) => { 39 | * // handle Request, return a Response 40 | * return new Response("Hello!"); 41 | * }); 42 | * 43 | * const resp = await fetch('/:/' + ...); // routed by service worker to your handler! 44 | */ 45 | 46 | if (globalThis["ServiceWorkerGlobalScope"] && self instanceof ServiceWorkerGlobalScope) { 47 | const registered = new Map(); 48 | 49 | async function cleanupDeadClients() { 50 | const clientsToDelete = []; 51 | for (const clientId of registered.keys()) { 52 | const client = await clients.get(clientId); 53 | if (!client) clientsToDelete.push(clientId); 54 | } 55 | for (const clientId of clientsToDelete) { 56 | registered.delete(clientId); 57 | } 58 | } 59 | 60 | self.addEventListener("install", () => self.skipWaiting()); // Activate immediately, don't wait 61 | self.addEventListener("activate", event => event.waitUntil(clients.claim())); // Take control of all pages immediately 62 | 63 | self.addEventListener("message", (event) => { 64 | if (event.data.responder) { 65 | registered.set(event.source.id, {clientId: event.source.id, ...event.data}); 66 | event.data.ready.postMessage(true); 67 | } 68 | }); 69 | 70 | self.addEventListener("fetch", async (event) => { 71 | // find the registration for the fetching client 72 | let registration = registered.get(event.clientId); 73 | if (!registration) { 74 | // no registration found, find the most recent one 75 | let last = null; 76 | for (const reg of registered.values()) { 77 | last = reg; 78 | } 79 | if (!last) { 80 | return; 81 | } 82 | registration = last; 83 | } 84 | 85 | const { timeout = 1000, prefix = "/" } = registration.options; 86 | 87 | const req = event.request; 88 | const url = new URL(req.url); 89 | if (!url.pathname.startsWith(prefix)) return; 90 | 91 | const headers = {} 92 | for (var p of req.headers) { 93 | headers[p[0]] = p[1] 94 | } 95 | 96 | event.respondWith(new Promise(async (resolve) => { 97 | await cleanupDeadClients(); // no awaits before respondWith 98 | 99 | const ch = new MessageChannel(); 100 | const response = new Promise(r => ch.port1.onmessage = e => r(e.data)); 101 | registration.responder.postMessage({ 102 | request: { 103 | method: req.method, 104 | url: req.url, 105 | headers: headers, 106 | }, 107 | responder: ch.port2 108 | }, [ch.port2]); 109 | try { 110 | const reply = await Promise.race([response, new Promise((_, reject) => { 111 | setTimeout(() => reject(new Error('Timeout')), timeout); 112 | })]); 113 | if (reply.error) { 114 | console.warn(reply.error); 115 | resolve(Response.error()); 116 | return; 117 | } 118 | resolve(new Response(reply.body, reply)); 119 | } catch (error) { 120 | console.error(error); 121 | resolve(Response.error()); 122 | } 123 | })) 124 | }); 125 | 126 | } 127 | 128 | export async function register(handler, options = {}) { 129 | const responder = new MessageChannel(); 130 | const ready = new MessageChannel(); 131 | 132 | if (!handler) { 133 | handler = () => new Response("No handler yet", { status: 503 }); 134 | } 135 | 136 | responder.port1.onmessage = async (event) => { 137 | const req = new Request(event.data.request.url, { 138 | method: event.data.request.method, 139 | headers: event.data.request.headers, 140 | body: event.data.request.body, 141 | }); 142 | const resp = await handler(req); 143 | event.data.responder.postMessage({ 144 | body: await resp.bytes(), 145 | headers: Object.fromEntries(resp.headers.entries()), 146 | status: resp.status, 147 | statusText: resp.statusText, 148 | }); 149 | }; 150 | 151 | await navigator.serviceWorker.register(import.meta.url, {type: "module", scope: options.scope || "/"}); 152 | const registration = await navigator.serviceWorker.ready; 153 | registration.active.postMessage({ 154 | responder: responder.port2, 155 | ready: ready.port2, 156 | options: options 157 | }, [responder.port2, ready.port2]); 158 | 159 | await new Promise(resolve => ready.port1.onmessage = resolve); 160 | 161 | return (h) => handler = h; 162 | } 163 | -------------------------------------------------------------------------------- /worker/cmd/worker/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/binary" 6 | "encoding/hex" 7 | "fmt" 8 | "log" 9 | "net" 10 | "net/http" 11 | "net/http/httputil" 12 | "net/url" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "github.com/gorilla/websocket" 18 | "github.com/progrium/go-netstack/vnet" 19 | ) 20 | 21 | func main() { 22 | vn, err := vnet.New(&vnet.Configuration{ 23 | Debug: false, 24 | MTU: 1500, 25 | Subnet: "10.0.0.0/8", 26 | GatewayIP: "10.0.0.1", 27 | GatewayMacAddress: "5a:94:ef:e4:0c:dd", 28 | GatewayVirtualIPs: []string{}, 29 | }) 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | 34 | if err := http.ListenAndServe(":8080", handler(vn)); err != nil { 35 | log.Fatal(err) 36 | } 37 | } 38 | 39 | func handler(vn *vnet.VirtualNetwork) http.Handler { 40 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 41 | // handle bundles 42 | if strings.HasPrefix(r.URL.Path, "/bundles/") { 43 | // some "redirects" to handle old bundles 44 | if r.URL.Path == "/bundles/gocache.tar.br" { 45 | r.URL.Path = "/bundles/gocache-386.tar.br" 46 | } 47 | 48 | w.Header().Set("Access-Control-Allow-Origin", "*") 49 | if strings.HasSuffix(r.URL.Path, ".gz") { 50 | w.Header().Set("Content-Encoding", "gzip") 51 | } else { 52 | w.Header().Set("Content-Encoding", "br") 53 | } 54 | http.StripPrefix("/bundles/", http.FileServer(http.Dir("bundles"))).ServeHTTP(w, r) 55 | return 56 | } 57 | 58 | // rest are network requests, so make sure the network is available 59 | if vn == nil { 60 | http.Error(w, "network not available", http.StatusNotFound) 61 | return 62 | } 63 | 64 | // handle port tunnel requests 65 | porthost := r.Host 66 | if r.URL.Query().Has("port") { 67 | porthost = r.URL.Query().Get("port") 68 | q := r.URL.Query() 69 | q.Del("port") 70 | r.URL.RawQuery = q.Encode() 71 | } 72 | parts := strings.Split(porthost, ".") 73 | parts = strings.Split(parts[0], "-") 74 | if parts[0] == "tcp" && len(parts) == 4 { 75 | port := parts[1] 76 | ip := parts[2] 77 | var err error 78 | ip, err = DecodeIP(ip) 79 | if err != nil { 80 | http.Error(w, err.Error(), http.StatusBadRequest) 81 | return 82 | } 83 | 84 | conn, err := vn.Dial("tcp", net.JoinHostPort(ip, port)) 85 | if err != nil { 86 | http.Error(w, err.Error(), http.StatusBadRequest) 87 | return 88 | } 89 | defer conn.Close() 90 | 91 | u, err := url.Parse(fmt.Sprintf("http://%s", net.JoinHostPort(ip, port))) 92 | if err != nil { 93 | http.Error(w, err.Error(), http.StatusBadRequest) 94 | return 95 | } 96 | 97 | proxy := CreateProxyWithConn(u, conn) 98 | proxy.ServeHTTP(w, r) 99 | return 100 | } 101 | 102 | if !websocket.IsWebSocketUpgrade(r) { 103 | http.Error(w, "expecting websocket upgrade", http.StatusBadRequest) 104 | return 105 | } 106 | 107 | ws, err := upgrader.Upgrade(w, r, nil) 108 | if err != nil { 109 | http.Error(w, err.Error(), http.StatusInternalServerError) 110 | log.Println(err) 111 | return 112 | } 113 | defer ws.Close() 114 | 115 | fmt.Println("network session started") 116 | 117 | if err := vn.AcceptQemu(r.Context(), &qemuAdapter{Conn: ws}); err != nil { 118 | if strings.Contains(err.Error(), "websocket: close") { 119 | return 120 | } 121 | log.Println(err) 122 | return 123 | } 124 | }) 125 | } 126 | 127 | var upgrader = websocket.Upgrader{ 128 | ReadBufferSize: 1024, 129 | WriteBufferSize: 1024, 130 | CheckOrigin: func(r *http.Request) bool { 131 | return true 132 | }, 133 | } 134 | 135 | type qemuAdapter struct { 136 | *websocket.Conn 137 | mu sync.Mutex 138 | readBuffer []byte 139 | writeBuffer []byte 140 | readOffset int 141 | } 142 | 143 | func (q *qemuAdapter) Read(p []byte) (n int, err error) { 144 | if len(q.readBuffer) == 0 { 145 | _, message, err := q.ReadMessage() 146 | if err != nil { 147 | return 0, err 148 | } 149 | length := uint32(len(message)) 150 | lengthPrefix := make([]byte, 4) 151 | binary.BigEndian.PutUint32(lengthPrefix, length) 152 | q.readBuffer = append(lengthPrefix, message...) 153 | q.readOffset = 0 154 | } 155 | 156 | n = copy(p, q.readBuffer[q.readOffset:]) 157 | q.readOffset += n 158 | if q.readOffset >= len(q.readBuffer) { 159 | q.readBuffer = nil 160 | } 161 | return n, nil 162 | } 163 | 164 | func (q *qemuAdapter) Write(p []byte) (int, error) { 165 | q.mu.Lock() 166 | defer q.mu.Unlock() 167 | 168 | q.writeBuffer = append(q.writeBuffer, p...) 169 | 170 | if len(q.writeBuffer) < 4 { 171 | return len(p), nil 172 | } 173 | 174 | length := binary.BigEndian.Uint32(q.writeBuffer[:4]) 175 | if len(q.writeBuffer) < int(length)+4 { 176 | return len(p), nil 177 | } 178 | 179 | err := q.WriteMessage(websocket.BinaryMessage, q.writeBuffer[4:4+length]) 180 | if err != nil { 181 | return 0, err 182 | } 183 | 184 | q.writeBuffer = q.writeBuffer[4+length:] 185 | return len(p), nil 186 | } 187 | 188 | func (c *qemuAdapter) LocalAddr() net.Addr { 189 | return &net.UnixAddr{} 190 | } 191 | 192 | func (c *qemuAdapter) RemoteAddr() net.Addr { 193 | return &net.UnixAddr{} 194 | } 195 | 196 | func (c *qemuAdapter) SetDeadline(t time.Time) error { 197 | return nil 198 | } 199 | func (c *qemuAdapter) SetReadDeadline(t time.Time) error { 200 | return nil 201 | } 202 | func (c *qemuAdapter) SetWriteDeadline(t time.Time) error { 203 | return nil 204 | } 205 | 206 | // DecodeIP converts "HHHHHHHH" hex to "IP" 207 | func DecodeIP(encoded string) (string, error) { 208 | ipBytes, err := hex.DecodeString(encoded) 209 | if err != nil || len(ipBytes) != 4 { 210 | return "", fmt.Errorf("invalid IP hex") 211 | } 212 | ip := net.IPv4(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3]) 213 | return ip.String(), nil 214 | } 215 | 216 | // EncodeIP converts an IPv4 string (e.g. "127.0.0.1") to its "HHHHHHHH" hex format. 217 | func EncodeIP(ipstr string) (string, error) { 218 | ip := net.ParseIP(ipstr) 219 | if ip == nil { 220 | return "", fmt.Errorf("invalid IP address") 221 | } 222 | ipv4 := ip.To4() 223 | if ipv4 == nil { 224 | return "", fmt.Errorf("not an IPv4 address") 225 | } 226 | return hex.EncodeToString(ipv4), nil 227 | } 228 | 229 | // CustomDialer wraps a specific net.Conn to be used by the HTTP transport 230 | type CustomDialer struct { 231 | conn net.Conn 232 | } 233 | 234 | func (d *CustomDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { 235 | // Return the pre-established connection 236 | // Note: This simple implementation returns the same conn every time 237 | // You may want to handle connection reuse/pooling differently 238 | return d.conn, nil 239 | } 240 | 241 | // CreateProxyWithConn creates a reverse proxy that uses a specific net.Conn 242 | func CreateProxyWithConn(targetURL *url.URL, conn net.Conn) *httputil.ReverseProxy { 243 | proxy := httputil.NewSingleHostReverseProxy(targetURL) 244 | 245 | // Create a custom transport that uses our specific connection 246 | transport := &http.Transport{ 247 | DialContext: (&CustomDialer{conn: conn}).DialContext, 248 | // Disable connection pooling since we're managing the connection manually 249 | MaxIdleConns: 1, 250 | MaxIdleConnsPerHost: 1, 251 | DisableKeepAlives: false, 252 | IdleConnTimeout: 90 * time.Second, 253 | } 254 | 255 | proxy.Transport = transport 256 | 257 | return proxy 258 | } 259 | -------------------------------------------------------------------------------- /assets/dashboard.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Apptron 6 | 7 | 8 | 9 | 10 | 30 | 31 | 32 | 33 |
34 | 35 |
36 | 37 |
38 |
39 |

Projects

40 | 42 |
43 | 44 |
45 | 46 |
47 |
48 | 49 | 205 | 206 |
207 |
208 | 209 | 210 | 211 | -------------------------------------------------------------------------------- /worker/src/projects.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "./context"; 2 | import { validateToken } from "./auth"; 3 | import { uuidv4, putdir, deletepath, copypath, checkpath } from "./util"; 4 | import { getAttrs } from "./r2fs"; 5 | 6 | export async function handle(req: Request, env: any, ctx: Context) { 7 | 8 | // todo: keep in mind cross origin possibility 9 | if (!await validateToken(env.AUTH_URL, ctx.tokenRaw)) { 10 | return new Response("Forbidden", { status: 403 }); 11 | } 12 | 13 | const url = new URL(req.url); 14 | const urlParts = url.pathname.split("/"); 15 | const pathParts = urlParts.slice(urlParts.indexOf("projects")); 16 | 17 | switch (req.method) { 18 | case "GET": 19 | case "HEAD": 20 | if (pathParts.length === 1) { 21 | // /projects 22 | return handleGetAll(req, env, ctx); 23 | } 24 | if (pathParts.length === 2) { 25 | // /projects/:project 26 | return handleGetOne(req, env, ctx); 27 | } 28 | case "POST": 29 | if (pathParts.length === 1) { 30 | // /projects 31 | return handlePost(req, env, ctx); 32 | } 33 | case "PUT": 34 | if (pathParts.length === 2) { 35 | // /projects/:project 36 | return handlePut(req, env, ctx); 37 | } 38 | case "DELETE": 39 | if (pathParts.length === 2) { 40 | // /projects/:project 41 | return handleDelete(req, env, ctx); 42 | } 43 | } 44 | return new Response("Method Not Allowed", { status: 405 }); 45 | } 46 | 47 | // export async function handlePostPublish(req: Request, env: any, ctx: Context) { 48 | // const url = new URL(req.url); 49 | // const pathParts = url.pathname.split("/"); 50 | // const projectName = pathParts.slice(-2)[0]; 51 | 52 | // const project = await getByName(env, ctx.username, projectName); 53 | // if (!project) { 54 | // return new Response("Not Found", { status: 404 }); 55 | // } 56 | 57 | // if (!project["publish_source"]) { 58 | // return new Response("Bad Request", { status: 400 }); 59 | // } 60 | 61 | // const checkResp = await checkpath(req, env, `/env/${project["uuid"]}/project/${project["publish_source"]}`); 62 | // if (checkResp.ok) { 63 | // return new Response("Not Found", { status: 404 }); 64 | // } 65 | 66 | // const resp = await copypath(req, env, `/env/${project["uuid"]}/public`, `/env/${project["uuid"]}/publish`); 67 | // if (!resp.ok) { 68 | // return resp; 69 | // } 70 | 71 | // return new Response(projectName, { status: 200 }); 72 | // } 73 | 74 | export async function handleGetAll(req: Request, env: any, ctx: Context) { 75 | const projects = await list(env, ctx.username); 76 | return new Response(JSON.stringify(projects), { status: 200 }); 77 | } 78 | 79 | export async function handleGetOne(req: Request, env: any, ctx: Context) { 80 | const url = new URL(req.url); 81 | const projectName = url.pathname.split("/").pop() || ""; 82 | if (!projectName) { 83 | return new Response("Bad Request", { status: 400 }); 84 | } 85 | const project = await getByName(env, ctx.username, projectName); 86 | if (!project) { 87 | return new Response("Not Found", { status: 404 }); 88 | } 89 | return new Response(JSON.stringify(project), { status: 200 }); 90 | } 91 | 92 | export async function handlePost(req: Request, env: any, ctx: Context) { 93 | const project = await req.json(); 94 | if (!project["name"]) { 95 | return new Response("Bad Request", { status: 400 }); 96 | } 97 | let name = project["name"].trim(); 98 | // Remove all characters except alphanumeric, dash, and underscore, replace spaces with dashes 99 | name = name.replace(/\s+/g, "-").replace(/[^A-Za-z0-9\-_]/g, ""); 100 | project["name"] = name; 101 | project["uuid"] = uuidv4(); 102 | 103 | let resp; 104 | resp = await putdir(req, env, `/env/${project["uuid"]}`, { 105 | "name": project["name"], 106 | "owner": ctx.userUUID, 107 | "ownername": ctx.username, 108 | }); 109 | if (!resp.ok) { 110 | return resp; 111 | } 112 | 113 | resp = await putdir(req, env, `/env/${project["uuid"]}/project`); 114 | if (!resp.ok) { 115 | await deletepath(req, env, `/env/${project["uuid"]}`); 116 | return resp; 117 | } 118 | 119 | resp = await putdir(req, env, `/etc/index/${ctx.username}/${project["name"]}`, { 120 | "uuid": project["uuid"], 121 | "name": project["name"], 122 | "description": project["description"] || "", 123 | "owner": ctx.userUUID, 124 | "visibility": project["visibility"] || "private", 125 | }); 126 | if (!resp.ok) { 127 | await deletepath(req, env, `/env/${project["uuid"]}`); 128 | return resp; 129 | } 130 | 131 | const projectURL = new URL(req.url); 132 | projectURL.pathname = `/edit/${project["name"]}`; 133 | return new Response(null, { status: 201, headers: { "Location": projectURL.toString() } }); 134 | } 135 | 136 | export async function handlePut(req: Request, env: any, ctx: Context) { 137 | const url = new URL(req.url); 138 | 139 | if (!url.pathname.startsWith("/projects/")) { 140 | return new Response("Not Found", { status: 404 }); 141 | } 142 | 143 | const projectName = url.pathname.split("/").pop() || ""; 144 | if (!projectName) { 145 | return new Response("Bad Request", { status: 400 }); 146 | } 147 | 148 | const update = await req.json(); 149 | 150 | // Look up existing project metadata 151 | const attrs = await getAttrs(env.bucket, `/etc/index/${ctx.username}/${projectName}`); 152 | if (!attrs) { 153 | return new Response("Not Found", { status: 404 }); 154 | } 155 | 156 | // Update description (and other metadata if you like) 157 | const newAttrs = { 158 | "uuid": attrs["uuid"], 159 | "owner": ctx.userUUID, 160 | "ownername": ctx.username, 161 | "name": projectName, 162 | "description": update["description"] || attrs["description"] || "", 163 | "visibility": update["visibility"] || attrs["visibility"] || "private", 164 | "publish_source": update["publish_source"] || attrs["publish_source"] || "", 165 | }; 166 | 167 | // Write updated attributes back using mkdir (idempotent PUT) 168 | const updateResp = await putdir(req, env, `/etc/index/${ctx.username}/${projectName}`, newAttrs); 169 | if (!updateResp.ok) { 170 | return updateResp; 171 | } 172 | 173 | // Create environment public directory if publish_source changed 174 | if (attrs["publish_source"] !== newAttrs["publish_source"]) { 175 | const publicResp = await putdir(req, env, `/env/${attrs["uuid"]}/public`); 176 | if (!publicResp.ok) { 177 | return publicResp; 178 | } 179 | } 180 | 181 | return new Response(JSON.stringify(newAttrs), { 182 | status: 200, 183 | headers: { "Content-Type": "application/json" }, 184 | }); 185 | } 186 | 187 | export async function handleDelete(req: Request, env: any, ctx: Context) { 188 | const url = new URL(req.url); 189 | let resp; 190 | 191 | if (!url.pathname.startsWith("/projects/")) { 192 | return new Response("Not Found", { status: 404 }); 193 | } 194 | const projectName = url.pathname.split("/").pop() || ""; 195 | if (!projectName) { 196 | return new Response("Not Found", { status: 404 }); 197 | } 198 | 199 | const attrs = await getAttrs(env.bucket, `/etc/index/${ctx.username}/${projectName}`); 200 | if (!attrs) { 201 | return new Response("Not Found", { status: 404 }); 202 | } 203 | 204 | resp = await deletepath(req, env, `/etc/index/${ctx.username}/${projectName}`); 205 | if (!resp.ok) { 206 | return resp; 207 | } 208 | 209 | resp = await deletepath(req, env, `/env/${attrs["uuid"]}`); 210 | if (!resp.ok) { 211 | return resp; 212 | } 213 | 214 | return new Response(null, { status: 204 }); 215 | } 216 | 217 | export async function list(env: any, username: string): Promise[]> { 218 | const projects: Record[] = []; 219 | let cursor: string | undefined = undefined; 220 | do { 221 | const prefix = `/etc/index/${username}/`; 222 | const page = await env.bucket.list({ 223 | prefix, 224 | include: ["customMetadata"], 225 | cursor, 226 | limit: 1000, 227 | }); 228 | for (const obj of page.objects || []) { 229 | const project = { 230 | name: obj.key.slice(prefix.length), 231 | visibility: "private", // default 232 | }; 233 | for (const [key, value] of Object.entries(obj.customMetadata)) { 234 | if (key.startsWith("Attribute-")) { 235 | project[key.slice(10)] = value; 236 | } 237 | } 238 | projects.push(project); 239 | } 240 | cursor = page.truncated ? page.cursor : undefined; 241 | } while (cursor); 242 | return projects; 243 | } 244 | 245 | export async function getByName(env: any, username: string, projectName: string): Promise | null> { 246 | const attrs = await getAttrs(env.bucket, `/etc/index/${username}/${projectName}`); 247 | if (!attrs) { 248 | return null; 249 | } 250 | return attrs; 251 | } 252 | 253 | export async function getByUUID(env: any, uuid: string): Promise | null> { 254 | const envAttrs = await getAttrs(env.bucket, `/env/${uuid}`); 255 | if (!envAttrs) { 256 | return null; 257 | } 258 | const userAttrs = await getAttrs(env.bucket, `/usr/${envAttrs["owner"]}`); 259 | if (!userAttrs) { 260 | return null; 261 | } 262 | const project = await getByName(env, userAttrs["username"], envAttrs["name"]); 263 | if (!project) { 264 | return null; 265 | } 266 | if (!project["ownername"]) { 267 | project["ownername"] = userAttrs["username"]; 268 | } 269 | if (!project["name"]) { 270 | project["name"] = envAttrs["name"]; 271 | } 272 | return project; 273 | } -------------------------------------------------------------------------------- /worker/src/worker.ts: -------------------------------------------------------------------------------- 1 | import { Container, getContainer } from "@cloudflare/containers"; 2 | import { validateToken } from "./auth"; 3 | import { handle as handleR2FS, getAttrs } from "./r2fs"; 4 | import { isLocal, redirectToSignin, insertMeta, insertHTML, uuidv4, putdir } from "./util"; 5 | import { ADMIN_USERS, HOST_DOMAIN, PUBLISH_DOMAINS } from "./config"; 6 | import { Context, parseContext } from "./context"; 7 | import * as projects from "./projects"; 8 | import * as publicsite from "./public"; 9 | export class Session extends Container { 10 | defaultPort = 8080; 11 | sleepAfter = "1h"; 12 | } 13 | 14 | const CORS_HEADERS = { 15 | "Access-Control-Allow-Origin": "*", 16 | "Access-Control-Allow-Methods": "GET, HEAD, PUT, POST, PATCH, DELETE, MOVE, COPY, OPTIONS", 17 | "Access-Control-Allow-Headers": "Content-Type, Authorization", 18 | "Vary": "Origin", 19 | }; 20 | 21 | function applyCORS(resp: Response) { 22 | const newresp = new Response(resp.body, { 23 | status: resp.status, 24 | statusText: resp.statusText, 25 | headers: new Headers(resp.headers) 26 | }); 27 | for (const [key, value] of Object.entries(CORS_HEADERS)) { 28 | newresp.headers.set(key, value); 29 | } 30 | return newresp; 31 | } 32 | 33 | export default { 34 | async fetch(req: Request, env: any) { 35 | const url = new URL(req.url); 36 | const ctx = parseContext(req, env); 37 | 38 | if ( 39 | ctx.portDomain || 40 | url.pathname.startsWith("/x/net") || 41 | url.pathname.startsWith("/bundles/") 42 | ) { 43 | return getContainer(env.session).fetch(req); 44 | } 45 | 46 | if (req.method === "OPTIONS") { 47 | return new Response("", { 48 | status: 200, 49 | headers: {...CORS_HEADERS}, 50 | }); 51 | } 52 | 53 | for (const domain of PUBLISH_DOMAINS) { 54 | if ((isLocal(env) && (url.pathname.split("/")[1]||"").endsWith("."+domain)) 55 | || url.host.endsWith("."+domain)) { 56 | return publicsite.handle(req, env, ctx); 57 | } 58 | } 59 | 60 | if (url.pathname.endsWith(".map")) { 61 | return new Response("", { status: 200 }); 62 | } 63 | 64 | if (ctx.envDomain && url.pathname.startsWith("/edit/")) { 65 | const project = await projects.getByUUID(env, ctx.subdomain); 66 | if (project === null) { 67 | return new Response("Not Found", { status: 404 }); 68 | } 69 | if (project["visibility"] !== "public" && project["owner"] !== ctx.userUUID) { 70 | return new Response("Forbidden", { status: 403 }); 71 | } 72 | 73 | const envReq = new Request(new URL("/_env", req.url).toString(), req); 74 | const envResp = await env.assets.fetch(envReq); 75 | const resp = new Response(envResp.body, { 76 | status: envResp.status, 77 | statusText: envResp.statusText, 78 | headers: new Headers(envResp.headers) 79 | }); 80 | // resp.headers.set("Cross-Origin-Opener-Policy", "same-origin"); 81 | // resp.headers.set("Cross-Origin-Embedder-Policy", "require-corp"); 82 | // resp.headers.set("Cross-Origin-Resource-Policy", "cross-origin"); 83 | 84 | const contentType = resp.headers.get('content-type'); 85 | if (!contentType || !contentType.includes('text/html')) { 86 | return resp; 87 | } 88 | 89 | return insertMeta(resp, { 90 | "auth-url": env.AUTH_URL, 91 | "project": escapeJSON(JSON.stringify(project)), 92 | }); 93 | } 94 | 95 | if (["/dashboard", "/shell"].includes(url.pathname)) { 96 | if (!await validateToken(env.AUTH_URL, ctx.tokenRaw)) { 97 | return redirectToSignin(env, url); 98 | } 99 | } 100 | 101 | if (url.pathname.startsWith("/data")) { 102 | const authenticated = await validateToken(env.AUTH_URL, ctx.tokenRaw); 103 | 104 | let dataPath = url.pathname.slice(5); 105 | if (dataPath.endsWith("/...")) { 106 | dataPath = dataPath.slice(0, -4); 107 | } 108 | // admin data urls 109 | if (dataPath.includes("/:attr/") || 110 | dataPath.startsWith("/etc/") || 111 | ["","/etc","/env","/usr"].indexOf(dataPath) !== -1) { 112 | if (!authenticated) { 113 | return new Response("Forbidden", { status: 403 }); 114 | } 115 | if (!ADMIN_USERS.includes(ctx.tokenJWT?.username)) { 116 | return new Response("Forbidden", { status: 403 }); 117 | } 118 | } 119 | // user data urls 120 | if (dataPath.startsWith("/usr/")) { 121 | if (!authenticated) { 122 | return new Response("Forbidden", { status: 403 }); 123 | } 124 | const parts = dataPath.split("/"); 125 | if (!parts[2] || parts[2] !== ctx.userUUID) { 126 | return new Response("Forbidden", { status: 403 }); 127 | } 128 | } 129 | // env data urls 130 | if (dataPath.startsWith("/env/")) { 131 | const envUUID = dataPath.split("/")[2]; 132 | const project = await projects.getByUUID(env, envUUID); 133 | if (project === null) { 134 | return new Response("Not Found", { status: 404 }); 135 | } 136 | // not public and not owner 137 | if (project["visibility"] !== "public" && project["owner"] !== ctx.userUUID) { 138 | return new Response("Forbidden", { status: 403 }); 139 | } 140 | // public, not owner, and not GET or HEAD request 141 | if (project["visibility"] === "public" && project["owner"] !== ctx.userUUID && ["GET", "HEAD"].indexOf(req.method) === -1) { 142 | return new Response("Forbidden", { status: 403 }); 143 | } 144 | } 145 | return handleR2FS(req, env, "/data"); 146 | } 147 | 148 | // .apptron.dev/edit/ 149 | if (url.pathname.startsWith("/edit/")) { 150 | const parts = url.pathname.split("/"); 151 | const envName = parts[2]; 152 | const project = await projects.getByName(env, ctx.subdomain, envName); 153 | if (project === null) { 154 | return new Response("Not Found", { status: 404 }); 155 | } 156 | if (project["visibility"] !== "public" && project["owner"] !== ctx.userUUID) { 157 | return new Response("Not Found", { status: 404 }); 158 | // return new Response("Forbidden", { status: 403 }); 159 | } 160 | return await envPage(req, env, project, "/edit/"+envName); 161 | } 162 | 163 | if (url.pathname === "/" && req.method === "GET") { 164 | await ensureSystemDirs(req, env); 165 | return redirectToSignin(env, url); 166 | } 167 | 168 | if (ctx.userDomain && url.pathname === "/" && req.method === "PUT") { 169 | // ensure user is set up 170 | if (!await validateToken(env.AUTH_URL, ctx.tokenRaw)) { 171 | return new Response("Forbidden", { status: 403 }); 172 | } 173 | const user = await req.json(); 174 | 175 | const usrResp = await putdir(req, env, `/usr/${user["user_id"]}`, { 176 | "username": user["username"], 177 | }); 178 | if (!usrResp.ok) { 179 | return usrResp; 180 | } 181 | 182 | const idxResp = await putdir(req, env, `/etc/index/${user["username"]}`, { 183 | "uuid": user["user_id"], 184 | }); 185 | if (!idxResp.ok) { 186 | return idxResp; 187 | } 188 | 189 | return new Response(null, { status: 204 }); 190 | } 191 | 192 | if (ctx.userDomain && url.pathname.startsWith("/projects")) { 193 | return applyCORS(await projects.handle(req, env, ctx)); 194 | } 195 | 196 | if (["/signin", "/signout", "/shell", "/dashboard", "/debug"].includes(url.pathname)) { 197 | const resp = await env.assets.fetch(req); 198 | 199 | const contentType = resp.headers.get('content-type'); 200 | if (!contentType || !contentType.includes('text/html')) { 201 | return resp; 202 | } 203 | 204 | return insertMeta(resp, { 205 | "auth-url": env.AUTH_URL 206 | }); 207 | } 208 | 209 | return env.assets.fetch(req); 210 | }, 211 | }; 212 | 213 | function escapeJSON(json: string) { 214 | return json.replace(/"/g, '"').replace(//g, '>'); 215 | } 216 | 217 | function ensureSystemDirs(req: Request, env: any) { 218 | console.log("Ensuring system directories exist..."); 219 | return Promise.all([ 220 | putdir(req, env, "/"), 221 | putdir(req, env, "/etc"), 222 | putdir(req, env, "/etc/index"), 223 | putdir(req, env, "/usr"), 224 | putdir(req, env, "/env"), 225 | ]); 226 | } 227 | 228 | 229 | async function envPage(req: Request, env: any, project: any, path: string) { 230 | const url = new URL(req.url); 231 | if (isLocal(env)) { 232 | url.searchParams.set("env", project["uuid"]); 233 | url.host = env.LOCALHOST; 234 | } else { 235 | url.host = project["uuid"] + "." + HOST_DOMAIN; 236 | } 237 | url.pathname = path; 238 | const envReq = new Request(new URL("/_frame", req.url).toString(), req); 239 | const envResp = await env.assets.fetch(envReq); 240 | const resp = new Response(envResp.body, { 241 | status: envResp.status, 242 | statusText: envResp.statusText, 243 | headers: new Headers(envResp.headers) 244 | }); 245 | // resp.headers.set("Cross-Origin-Opener-Policy", "same-origin"); 246 | // resp.headers.set("Cross-Origin-Embedder-Policy", "require-corp"); 247 | // resp.headers.set("Cross-Origin-Resource-Policy", "cross-origin"); 248 | return insertHTML(resp, "body", ``); 249 | } 250 | //sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox" 251 | 252 | -------------------------------------------------------------------------------- /extension/system/themes/Tractor-color-theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "vscode://schemas/color-theme", 3 | "name": "Tractor Dark", 4 | "colors": { 5 | "titleBar.activeBackground": "#212121", 6 | "activityBar.background": "#212121", 7 | "editorGroupHeader.tabsBackground": "#212121", 8 | "statusBarItem.remoteBackground": "#212121", 9 | "statusBar.background": "#212121", 10 | 11 | "sideBar.background": "#262626", 12 | "tab.inactiveBackground": "#262626", 13 | 14 | "editor.background": "#2D2D2D", 15 | "terminal.background": "#2D2D2D", 16 | "panel.background": "#2D2D2D", 17 | 18 | "activityBar.border": "#1A1A1A", 19 | "sideBar.border": "#1A1A1A", 20 | "titleBar.border": "#1A1A1A", 21 | "statusBar.border": "#1A1A1A", 22 | "tab.border": "#1A1A1A", 23 | "sideBarSectionHeader.border": "#1A1A1A", 24 | "editorGroupHeader.tabsBorder": "#1A1A1A", 25 | "panel.border": "#1A1A1A", 26 | 27 | 28 | 29 | "button.background": "#F0B80E", 30 | "badge.background": "#F0B80E", 31 | "activityBarBadge.background":"#F0B80E", 32 | "sideBarSectionHeader.foreground": "#F0B80E", 33 | "terminal.ansiYellow": "#F0B80E", 34 | 35 | "button.foreground": "#000000", 36 | "badge.foreground": "#000000", 37 | "activityBarBadge.foreground": "#000000", 38 | 39 | "notificationsInfoIcon.foreground": "#B28600", 40 | "focusBorder": "#664D00", 41 | "list.activeSelectionBackground": "#664D00", 42 | 43 | "checkbox.border": "#6B6B6B", 44 | "editor.foreground": "#D4D4D4", 45 | "editor.inactiveSelectionBackground": "#3A3D41", 46 | "editorIndentGuide.background1": "#404040", 47 | "editorIndentGuide.activeBackground1": "#707070", 48 | "editor.selectionHighlightBackground": "#ADD6FF26", 49 | "list.dropBackground": "#383B3D", 50 | "sideBarTitle.foreground": "#BBBBBB", 51 | "input.placeholderForeground": "#A6A6A6", 52 | "menu.background": "#252526", 53 | "menu.foreground": "#CCCCCC", 54 | "menu.separatorBackground": "#454545", 55 | "menu.border": "#454545", 56 | "menu.selectionBackground": "#0078d4", 57 | "statusBarItem.remoteForeground": "#FFF", 58 | "ports.iconRunningProcessForeground": "#369432", 59 | "sideBarSectionHeader.background": "#0000", 60 | "tab.selectedBackground": "#222222", 61 | "tab.selectedForeground": "#ffffffa0", 62 | "tab.lastPinnedBorder": "#ccc3", 63 | "list.activeSelectionIconForeground": "#FFF", 64 | "terminal.inactiveSelectionBackground": "#3A3D41", 65 | "widget.border": "#303031", 66 | "actionBar.toggledBackground": "#383a49" 67 | }, 68 | "tokenColors": [ 69 | { 70 | "scope": [ 71 | "meta.embedded", 72 | "source.groovy.embedded", 73 | "string meta.image.inline.markdown", 74 | "variable.legacy.builtin.python" 75 | ], 76 | "settings": { 77 | "foreground": "#D4D4D4" 78 | } 79 | }, 80 | { 81 | "scope": "emphasis", 82 | "settings": { 83 | "fontStyle": "italic" 84 | } 85 | }, 86 | { 87 | "scope": "strong", 88 | "settings": { 89 | "fontStyle": "bold" 90 | } 91 | }, 92 | { 93 | "scope": "header", 94 | "settings": { 95 | "foreground": "#000080" 96 | } 97 | }, 98 | { 99 | "scope": "comment", 100 | "settings": { 101 | "foreground": "#6A9955" 102 | } 103 | }, 104 | { 105 | "scope": "constant.language", 106 | "settings": { 107 | "foreground": "#569cd6" 108 | } 109 | }, 110 | { 111 | "scope": [ 112 | "constant.numeric", 113 | "variable.other.enummember", 114 | "keyword.operator.plus.exponent", 115 | "keyword.operator.minus.exponent" 116 | ], 117 | "settings": { 118 | "foreground": "#b5cea8" 119 | } 120 | }, 121 | { 122 | "scope": "constant.regexp", 123 | "settings": { 124 | "foreground": "#646695" 125 | } 126 | }, 127 | { 128 | "scope": "entity.name.tag", 129 | "settings": { 130 | "foreground": "#569cd6" 131 | } 132 | }, 133 | { 134 | "scope": [ 135 | "entity.name.tag.css", 136 | "entity.name.tag.less" 137 | ], 138 | "settings": { 139 | "foreground": "#d7ba7d" 140 | } 141 | }, 142 | { 143 | "scope": "entity.other.attribute-name", 144 | "settings": { 145 | "foreground": "#9cdcfe" 146 | } 147 | }, 148 | { 149 | "scope": [ 150 | "entity.other.attribute-name.class.css", 151 | "source.css entity.other.attribute-name.class", 152 | "entity.other.attribute-name.id.css", 153 | "entity.other.attribute-name.parent-selector.css", 154 | "entity.other.attribute-name.parent.less", 155 | "source.css entity.other.attribute-name.pseudo-class", 156 | "entity.other.attribute-name.pseudo-element.css", 157 | "source.css.less entity.other.attribute-name.id", 158 | "entity.other.attribute-name.scss" 159 | ], 160 | "settings": { 161 | "foreground": "#d7ba7d" 162 | } 163 | }, 164 | { 165 | "scope": "invalid", 166 | "settings": { 167 | "foreground": "#f44747" 168 | } 169 | }, 170 | { 171 | "scope": "markup.underline", 172 | "settings": { 173 | "fontStyle": "underline" 174 | } 175 | }, 176 | { 177 | "scope": "markup.bold", 178 | "settings": { 179 | "fontStyle": "bold", 180 | "foreground": "#569cd6" 181 | } 182 | }, 183 | { 184 | "scope": "markup.heading", 185 | "settings": { 186 | "fontStyle": "bold", 187 | "foreground": "#569cd6" 188 | } 189 | }, 190 | { 191 | "scope": "markup.italic", 192 | "settings": { 193 | "fontStyle": "italic" 194 | } 195 | }, 196 | { 197 | "scope": "markup.strikethrough", 198 | "settings": { 199 | "fontStyle": "strikethrough" 200 | } 201 | }, 202 | { 203 | "scope": "markup.inserted", 204 | "settings": { 205 | "foreground": "#b5cea8" 206 | } 207 | }, 208 | { 209 | "scope": "markup.deleted", 210 | "settings": { 211 | "foreground": "#ce9178" 212 | } 213 | }, 214 | { 215 | "scope": "markup.changed", 216 | "settings": { 217 | "foreground": "#569cd6" 218 | } 219 | }, 220 | { 221 | "scope": "punctuation.definition.quote.begin.markdown", 222 | "settings": { 223 | "foreground": "#6A9955" 224 | } 225 | }, 226 | { 227 | "scope": "punctuation.definition.list.begin.markdown", 228 | "settings": { 229 | "foreground": "#6796e6" 230 | } 231 | }, 232 | { 233 | "scope": "markup.inline.raw", 234 | "settings": { 235 | "foreground": "#ce9178" 236 | } 237 | }, 238 | { 239 | "name": "brackets of XML/HTML tags", 240 | "scope": "punctuation.definition.tag", 241 | "settings": { 242 | "foreground": "#808080" 243 | } 244 | }, 245 | { 246 | "scope": [ 247 | "meta.preprocessor", 248 | "entity.name.function.preprocessor" 249 | ], 250 | "settings": { 251 | "foreground": "#569cd6" 252 | } 253 | }, 254 | { 255 | "scope": "meta.preprocessor.string", 256 | "settings": { 257 | "foreground": "#ce9178" 258 | } 259 | }, 260 | { 261 | "scope": "meta.preprocessor.numeric", 262 | "settings": { 263 | "foreground": "#b5cea8" 264 | } 265 | }, 266 | { 267 | "scope": "meta.structure.dictionary.key.python", 268 | "settings": { 269 | "foreground": "#9cdcfe" 270 | } 271 | }, 272 | { 273 | "scope": "meta.diff.header", 274 | "settings": { 275 | "foreground": "#569cd6" 276 | } 277 | }, 278 | { 279 | "scope": "storage", 280 | "settings": { 281 | "foreground": "#569cd6" 282 | } 283 | }, 284 | { 285 | "scope": "storage.type", 286 | "settings": { 287 | "foreground": "#569cd6" 288 | } 289 | }, 290 | { 291 | "scope": [ 292 | "storage.modifier", 293 | "keyword.operator.noexcept" 294 | ], 295 | "settings": { 296 | "foreground": "#569cd6" 297 | } 298 | }, 299 | { 300 | "scope": [ 301 | "string", 302 | "meta.embedded.assembly" 303 | ], 304 | "settings": { 305 | "foreground": "#ce9178" 306 | } 307 | }, 308 | { 309 | "scope": "string.tag", 310 | "settings": { 311 | "foreground": "#ce9178" 312 | } 313 | }, 314 | { 315 | "scope": "string.value", 316 | "settings": { 317 | "foreground": "#ce9178" 318 | } 319 | }, 320 | { 321 | "scope": "string.regexp", 322 | "settings": { 323 | "foreground": "#d16969" 324 | } 325 | }, 326 | { 327 | "name": "String interpolation", 328 | "scope": [ 329 | "punctuation.definition.template-expression.begin", 330 | "punctuation.definition.template-expression.end", 331 | "punctuation.section.embedded" 332 | ], 333 | "settings": { 334 | "foreground": "#569cd6" 335 | } 336 | }, 337 | { 338 | "name": "Reset JavaScript string interpolation expression", 339 | "scope": [ 340 | "meta.template.expression" 341 | ], 342 | "settings": { 343 | "foreground": "#d4d4d4" 344 | } 345 | }, 346 | { 347 | "scope": [ 348 | "support.type.vendored.property-name", 349 | "support.type.property-name", 350 | "source.css variable", 351 | "source.coffee.embedded" 352 | ], 353 | "settings": { 354 | "foreground": "#9cdcfe" 355 | } 356 | }, 357 | { 358 | "scope": "keyword", 359 | "settings": { 360 | "foreground": "#569cd6" 361 | } 362 | }, 363 | { 364 | "scope": "keyword.control", 365 | "settings": { 366 | "foreground": "#569cd6" 367 | } 368 | }, 369 | { 370 | "scope": "keyword.operator", 371 | "settings": { 372 | "foreground": "#d4d4d4" 373 | } 374 | }, 375 | { 376 | "scope": [ 377 | "keyword.operator.new", 378 | "keyword.operator.expression", 379 | "keyword.operator.cast", 380 | "keyword.operator.sizeof", 381 | "keyword.operator.alignof", 382 | "keyword.operator.typeid", 383 | "keyword.operator.alignas", 384 | "keyword.operator.instanceof", 385 | "keyword.operator.logical.python", 386 | "keyword.operator.wordlike" 387 | ], 388 | "settings": { 389 | "foreground": "#569cd6" 390 | } 391 | }, 392 | { 393 | "scope": "keyword.other.unit", 394 | "settings": { 395 | "foreground": "#b5cea8" 396 | } 397 | }, 398 | { 399 | "scope": [ 400 | "punctuation.section.embedded.begin.php", 401 | "punctuation.section.embedded.end.php" 402 | ], 403 | "settings": { 404 | "foreground": "#569cd6" 405 | } 406 | }, 407 | { 408 | "scope": "support.function.git-rebase", 409 | "settings": { 410 | "foreground": "#9cdcfe" 411 | } 412 | }, 413 | { 414 | "scope": "constant.sha.git-rebase", 415 | "settings": { 416 | "foreground": "#b5cea8" 417 | } 418 | }, 419 | { 420 | "name": "coloring of the Java import and package identifiers", 421 | "scope": [ 422 | "storage.modifier.import.java", 423 | "variable.language.wildcard.java", 424 | "storage.modifier.package.java" 425 | ], 426 | "settings": { 427 | "foreground": "#d4d4d4" 428 | } 429 | }, 430 | { 431 | "name": "this.self", 432 | "scope": "variable.language", 433 | "settings": { 434 | "foreground": "#569cd6" 435 | } 436 | } 437 | ], 438 | "semanticHighlighting": true, 439 | "semanticTokenColors": { 440 | "newOperator": "#d4d4d4", 441 | "stringLiteral": "#ce9178", 442 | "customLiteral": "#D4D4D4", 443 | "numberLiteral": "#b5cea8", 444 | } 445 | } -------------------------------------------------------------------------------- /extension/system/src/web/bridge.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Disposable, 3 | Event, 4 | EventEmitter, 5 | FileChangeEvent, 6 | FileChangeType, 7 | FileStat, 8 | FileSystemError, 9 | FileSystemProvider, 10 | FileType, 11 | Uri, 12 | workspace, 13 | } from 'vscode'; 14 | 15 | //@ts-ignore 16 | import { WanixFS } from "../wanix/fs.js"; 17 | 18 | interface RemoteEntry { 19 | IsDir: boolean; 20 | Name: string; 21 | // Ctime: number; 22 | ModTime: number; 23 | Size: number; 24 | } 25 | 26 | export class File implements FileStat { 27 | 28 | type: FileType; 29 | ctime: number; 30 | mtime: number; 31 | size: number; 32 | 33 | name: string; 34 | 35 | constructor(public uri: Uri, entry: RemoteEntry) { 36 | this.type = FileType.File; 37 | this.ctime = 0; 38 | this.mtime = entry.ModTime || 0; 39 | this.size = entry.Size; 40 | this.name = entry.Name; 41 | } 42 | } 43 | 44 | export class Directory implements FileStat { 45 | 46 | type: FileType; 47 | ctime: number; 48 | mtime: number; 49 | size: number; 50 | 51 | name: string; 52 | 53 | constructor(public uri: Uri, entry: RemoteEntry) { 54 | this.type = FileType.Directory; 55 | this.ctime = 0; 56 | this.mtime = entry.ModTime || 0; 57 | this.size = entry.Size; 58 | this.name = entry.Name; 59 | } 60 | } 61 | 62 | export type Entry = File | Directory; 63 | 64 | export class WanixBridge implements FileSystemProvider, /*FileSearchProvider, TextSearchProvider,*/ Disposable { 65 | static scheme = 'wanix'; 66 | 67 | public wfsys: any; 68 | public readonly ready: Promise; 69 | private readonly disposable: Disposable; 70 | private root: string; 71 | 72 | constructor(wanixReceiver: MessagePort, root: string) { 73 | this.ready = new Promise((resolve) => { 74 | wanixReceiver.onmessage = async (event) => { 75 | if (event.data.wanix) { 76 | console.log("wanix port received"); 77 | const wfsys = new WanixFS(event.data.wanix); 78 | wfsys.waitFor("vm/1/fsys").then(() => { 79 | this.wfsys = wfsys; 80 | resolve(wfsys); 81 | }); 82 | } 83 | } 84 | }); 85 | this.root = root; 86 | this.disposable = Disposable.from( 87 | workspace.registerFileSystemProvider(WanixBridge.scheme, this, { isCaseSensitive: true }), 88 | // workspace.registerFileSearchProvider(MemFS.scheme, this), 89 | // workspace.registerTextSearchProvider(MemFS.scheme, this) 90 | ); 91 | } 92 | 93 | join(path: string): string { 94 | if (path === "/") { 95 | return this.root; 96 | } 97 | return this.root + path; 98 | } 99 | 100 | dispose() { 101 | this.disposable?.dispose(); 102 | } 103 | 104 | // --- manage file metadata 105 | 106 | stat(uri: Uri): Thenable { 107 | return this._stat(uri); 108 | } 109 | 110 | async _stat(uri: Uri): Promise { 111 | if (!this.wfsys) { 112 | if (uri.path !== "/project") { 113 | if (uri.path.includes(".vscode")) { 114 | throw FileSystemError.FileNotFound(uri); 115 | } 116 | return new File(uri, { 117 | IsDir: false, 118 | Name: this._basename(uri.path), 119 | ModTime: 0, 120 | Size: 0, 121 | }); 122 | } 123 | // todo: watch root to force reload? 124 | return new Directory(uri, { 125 | IsDir: true, 126 | Name: uri.path, 127 | ModTime: 0, 128 | Size: 0, 129 | }); 130 | } 131 | await this.ready; 132 | return await this._lookup(uri, false); 133 | } 134 | 135 | readDirectory(uri: Uri): Thenable<[string, FileType][]> { 136 | return this._readDirectory(uri); 137 | } 138 | 139 | async _readDirectory(uri: Uri): Promise<[string, FileType][]> { 140 | await this.ready; 141 | const entries = await this.wfsys.readDir(this.join(uri.path)); 142 | let result: [string, FileType][] = []; 143 | for (const entry of entries) { 144 | result.push([entry.replace(/\/$/, ''), (entry.endsWith('/')) ? FileType.Directory : FileType.File]); 145 | } 146 | return result; 147 | } 148 | 149 | // --- manage file contents 150 | 151 | readFile(uri: Uri): Thenable { 152 | return this._readFile(uri); 153 | } 154 | 155 | async _readFile(uri: Uri): Promise { 156 | await this.ready; 157 | return await this.wfsys.readFile(this.join(uri.path)); 158 | } 159 | 160 | writeFile(uri: Uri, content: Uint8Array, options: { create: boolean, overwrite: boolean }): Thenable { 161 | return this._writeFile(uri, content, options); 162 | } 163 | 164 | async _writeFile(uri: Uri, content: Uint8Array, options: { create: boolean, overwrite: boolean }): Promise { 165 | await this.ready; 166 | let entry = await this._lookup(uri, true); 167 | if (entry instanceof Directory) { 168 | throw FileSystemError.FileIsADirectory(uri); 169 | } 170 | if (!entry && !options.create) { 171 | throw FileSystemError.FileNotFound(uri); 172 | } 173 | if (entry && options.create && !options.overwrite) { 174 | throw FileSystemError.FileExists(uri); 175 | } 176 | 177 | await this.wfsys.writeFile(this.join(uri.path), content); 178 | 179 | if (!entry) { 180 | this._fireSoon({ type: FileChangeType.Created, uri }); 181 | } else { 182 | this._fireSoon({ type: FileChangeType.Changed, uri }); 183 | } 184 | this._fireSoon( 185 | { type: FileChangeType.Changed, uri: uri.with({ path: this._dirname(uri.path) }) } 186 | ); 187 | } 188 | 189 | // --- manage files/folders 190 | 191 | copy(source: Uri, destination: Uri, options: {overwrite: boolean}): Thenable { 192 | return this._copy(source, destination, options); 193 | } 194 | 195 | async _copy(source: Uri, destination: Uri, options: {overwrite: boolean}): Promise { 196 | await this.ready; 197 | if (!options.overwrite && await this._lookup(destination, true)) { 198 | throw FileSystemError.FileExists(destination); 199 | } 200 | 201 | await this.wfsys.copy(this.join(source.path), this.join(destination.path)); 202 | 203 | this._fireSoon( 204 | { type: FileChangeType.Changed, uri: destination.with({ path: this._dirname(destination.path) }) }, 205 | { type: FileChangeType.Created, uri: destination } 206 | ); 207 | } 208 | 209 | rename(oldUri: Uri, newUri: Uri, options: { overwrite: boolean }): Thenable { 210 | return this._rename(oldUri, newUri, options); 211 | } 212 | 213 | async _rename(oldUri: Uri, newUri: Uri, options: { overwrite: boolean }): Promise { 214 | await this.ready; 215 | if (!options.overwrite && await this._lookup(newUri, true)) { 216 | throw FileSystemError.FileExists(newUri); 217 | } 218 | 219 | await this.wfsys.rename(this.join(oldUri.path), this.join(newUri.path)); 220 | 221 | this._fireSoon( 222 | { type: FileChangeType.Changed, uri: oldUri.with({ path: this._dirname(oldUri.path) }) }, 223 | { type: FileChangeType.Deleted, uri: oldUri }, 224 | { type: FileChangeType.Changed, uri: newUri.with({ path: this._dirname(newUri.path) }) }, 225 | { type: FileChangeType.Created, uri: newUri } 226 | ); 227 | } 228 | 229 | delete(uri: Uri, options: {recursive: boolean}): Thenable { 230 | return this._delete(uri, options); 231 | } 232 | 233 | async _delete(uri: Uri, options: {recursive: boolean}): Promise { 234 | await this.ready; 235 | if (options.recursive) { 236 | await this.wfsys.removeAll(this.join(uri.path)); 237 | } else { 238 | await this.wfsys.remove(this.join(uri.path)); 239 | } 240 | 241 | this._fireSoon( 242 | { type: FileChangeType.Changed, uri: uri.with({ path: this._dirname(uri.path) }) }, 243 | { uri, type: FileChangeType.Deleted } 244 | ); 245 | } 246 | 247 | createDirectory(uri: Uri): Promise { 248 | return this._createDirectory(uri); 249 | } 250 | 251 | async _createDirectory(uri: Uri): Promise { 252 | await this.ready; 253 | await this.wfsys.makeDir(this.join(uri.path)); 254 | this._fireSoon( 255 | { type: FileChangeType.Changed, uri: uri.with({ path: this._dirname(uri.path) }) }, 256 | { type: FileChangeType.Created, uri } 257 | ); 258 | } 259 | 260 | // --- lookup 261 | 262 | private async _lookup(uri: Uri, silent: false): Promise; 263 | private async _lookup(uri: Uri, silent: boolean): Promise; 264 | private async _lookup(uri: Uri, silent: boolean): Promise { 265 | try { 266 | const entry = await this.wfsys.stat(this.join(uri.path)); 267 | if (entry.IsDir) { 268 | return new Directory(uri, entry); 269 | } else { 270 | return new File(uri, entry); 271 | } 272 | } catch (e) { 273 | if (!silent) { 274 | // console.error(e); 275 | throw FileSystemError.FileNotFound(uri); 276 | } else { 277 | return undefined; 278 | } 279 | } 280 | } 281 | 282 | private async _lookupAsDirectory(uri: Uri, silent: boolean): Promise { 283 | let entry = await this._lookup(uri, silent); 284 | if (entry instanceof Directory) { 285 | return entry; 286 | } 287 | throw FileSystemError.FileNotADirectory(uri); 288 | } 289 | 290 | private async _lookupAsFile(uri: Uri, silent: boolean): Promise { 291 | let entry = await this._lookup(uri, silent); 292 | if (entry instanceof File) { 293 | return entry; 294 | } 295 | throw FileSystemError.FileIsADirectory(uri); 296 | } 297 | 298 | private async _lookupParentDirectory(uri: Uri): Promise { 299 | const dirname = uri.with({ path: this._dirname(uri.path) }); 300 | return await this._lookupAsDirectory(dirname, false); 301 | } 302 | 303 | // --- manage file events 304 | 305 | private _emitter = new EventEmitter(); 306 | private _bufferedEvents: FileChangeEvent[] = []; 307 | private _fireSoonHandle?: any; 308 | 309 | readonly onDidChangeFile: Event = this._emitter.event; 310 | 311 | watch(_resource: Uri): Disposable { 312 | // ignore, fires for all changes... 313 | return new Disposable(() => { }); 314 | } 315 | 316 | private _fireSoon(...events: FileChangeEvent[]): void { 317 | this._bufferedEvents.push(...events); 318 | 319 | if (this._fireSoonHandle) { 320 | clearTimeout(this._fireSoonHandle); 321 | } 322 | 323 | this._fireSoonHandle = setTimeout(() => { 324 | this._emitter.fire(this._bufferedEvents); 325 | this._bufferedEvents.length = 0; 326 | }, 5); 327 | } 328 | 329 | // --- path utils 330 | 331 | private _basename(path: string): string { 332 | path = this._rtrim(path, '/'); 333 | if (!path) { 334 | return ''; 335 | } 336 | 337 | return path.substr(path.lastIndexOf('/') + 1); 338 | } 339 | 340 | private _dirname(path: string): string { 341 | path = this._rtrim(path, '/'); 342 | if (!path) { 343 | return '/'; 344 | } 345 | 346 | return path.substr(0, path.lastIndexOf('/')); 347 | } 348 | 349 | private _rtrim(haystack: string, needle: string): string { 350 | if (!haystack || !needle) { 351 | return haystack; 352 | } 353 | 354 | const needleLen = needle.length, 355 | haystackLen = haystack.length; 356 | 357 | if (needleLen === 0 || haystackLen === 0) { 358 | return haystack; 359 | } 360 | 361 | let offset = haystackLen, 362 | idx = -1; 363 | 364 | while (true) { 365 | idx = haystack.lastIndexOf(needle, offset - 1); 366 | if (idx === -1 || idx + needleLen !== offset) { 367 | break; 368 | } 369 | if (idx === 0) { 370 | return ''; 371 | } 372 | offset = idx; 373 | } 374 | 375 | return haystack.substring(0, offset); 376 | } 377 | 378 | } 379 | -------------------------------------------------------------------------------- /assets/lib/apptron.js: -------------------------------------------------------------------------------- 1 | import { WanixRuntime } from "/wanix.min.js"; 2 | import { register } from "/hanko/elements.js"; 3 | 4 | // querySelector conveniences, but dont import these in html components 5 | // because they have their own that work differently for shadowRoot 6 | export function $(selector) { return document.querySelector(selector); } 7 | export function $$(selector) { return document.querySelectorAll(selector); } 8 | 9 | export async function setupWanix() { 10 | const params = new URLSearchParams(window.location.search); 11 | if (params.get("cache") === "clear" || (isLocalhost() && !params.get("cache"))) { 12 | await clearAllCache("assets"); 13 | await clearAllCache("bundles"); 14 | } 15 | const w = new WanixRuntime({ 16 | helpers: true, 17 | debug9p: params.get('debug9p') === "true", 18 | wasm: null, 19 | network: params.get('network') || `${isLocalhost() ? "ws" : "wss"}://${appHost()}/x/net` 20 | }); 21 | // getting the bundle ourself, and the function to get other bundles 22 | w._bundle = getBundle("/bundles/sys.tar.gz"); 23 | w._getBundle = getBundle; 24 | // getting then loading the wasm ourselves 25 | getCachedOrFetch("/wanix.wasm").then(wasm => w._loadWasm(wasm)); 26 | return w; 27 | } 28 | 29 | let auth = null; 30 | export async function getAuth() { 31 | if (auth) { 32 | return auth; 33 | } 34 | if (!getMeta("auth-url")) { 35 | throw new Error("auth-url meta tag not found"); 36 | } 37 | const { hanko } = await register(getMeta("auth-url"), isLocalhost() ? undefined : { 38 | cookieDomain: "." + appHost() 39 | }); 40 | auth = hanko; 41 | auth.validatedSession = auth.validateSession(); 42 | auth.validatedSession.then(session => { 43 | if (session.is_valid) { 44 | console.log("valid session for user", session.claims.username); 45 | } 46 | }); 47 | return auth; 48 | } 49 | 50 | export function getMeta(name) { 51 | const meta = document.querySelector('meta[name="' + name + '"]'); 52 | if (!meta) { 53 | return null; 54 | } 55 | return meta.content; 56 | } 57 | 58 | export function isLocalhost() { 59 | const hostname = window.location.hostname; 60 | return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; 61 | } 62 | 63 | export function isUserDomain() { 64 | const params = new URLSearchParams(window.location.search); 65 | if (params.get("user")) { 66 | return true; 67 | } 68 | const subdomain = window.location.hostname.split(".").slice(0, -2).join("."); 69 | if (!subdomain) { 70 | return false; 71 | } 72 | if (subdomain.length >= 32) { 73 | // env domain 74 | return false; 75 | } 76 | return true; 77 | } 78 | 79 | export function isEnvDomain() { 80 | const params = new URLSearchParams(window.location.search); 81 | if (params.get("env")) { 82 | return true; 83 | } 84 | const subdomain = window.location.hostname.split(".").slice(0, -2).join("."); 85 | if (!subdomain) { 86 | return false; 87 | } 88 | if (subdomain.length < 32) { 89 | // user domain 90 | return false; 91 | } 92 | return true; 93 | } 94 | 95 | export function envUUID() { 96 | if (!isEnvDomain()) { 97 | return null; 98 | } 99 | const params = new URLSearchParams(window.location.search); 100 | if (params.get("env")) { 101 | return params.get("env"); 102 | } 103 | const subdomain = window.location.hostname.split(".").slice(0, -2).join("."); 104 | if (subdomain.length < 32) { 105 | return null; 106 | } 107 | return subdomain; 108 | } 109 | 110 | export async function envUsername() { 111 | const params = new URLSearchParams(window.location.search); 112 | if (params.get("user")) { 113 | return params.get("user"); 114 | } 115 | const hostname = new URL(await currentURL()).hostname; 116 | return hostname.split(".").slice(0, -2).join("."); 117 | } 118 | 119 | export function appHost() { 120 | const hostname = window.location.origin.replace("https://", "").replace("http://", ""); 121 | if (isLocalhost()) { 122 | return hostname; 123 | } 124 | const parts = hostname.split("."); 125 | if (parts.length >= 2) { 126 | return parts.slice(-2).join("."); 127 | } 128 | return hostname; 129 | } 130 | 131 | export function urlFor(path, params = {}, user = null) { 132 | let host = appHost(); 133 | if (user && !isLocalhost()) { 134 | host = user + "." + host; 135 | } 136 | const currentURL = new URL(window.location.href); 137 | const url = new URL(currentURL.protocol + "//" + host + path); 138 | if (params && Object.keys(params).length > 0) { 139 | for (const [key, value] of Object.entries(params)) { 140 | url.searchParams.set(key, value); 141 | } 142 | } 143 | if (user && isLocalhost()) { 144 | url.searchParams.set("user", user); 145 | } 146 | return url.toString() 147 | } 148 | 149 | export function currentURL() { 150 | if (isEnvDomain()) { 151 | const reply = new MessageChannel(); 152 | top.postMessage({ self: true, reply: reply.port2 }, getOrigin(), [reply.port2]); 153 | return new Promise((resolve, reject) => { 154 | reply.port1.onmessage = (e) => resolve(e.data); 155 | }); 156 | } 157 | return Promise.resolve(window.location.href); 158 | } 159 | 160 | export function getOrigin() { 161 | let origin = window.location.protocol + "//" + appHost(); 162 | if (window.apptron) { 163 | origin = window.location.protocol + "//" + window.apptron.env.ownername + "." + appHost(); 164 | } 165 | if (isLocalhost()) { 166 | origin = "*"; 167 | } 168 | return origin; 169 | } 170 | 171 | export function redirectTo(url) { 172 | if (isEnvDomain()) { 173 | top.postMessage({ redirect: url }, getOrigin()); 174 | return; 175 | } 176 | window.location.href = url; 177 | } 178 | 179 | export async function authRedirect(defaultTarget = "/", user = null) { 180 | const currentParams = new URLSearchParams(window.location.search); 181 | const redirect = currentParams.get("redirect") || defaultTarget; 182 | redirectTo(urlFor(redirect, {}, user)); 183 | } 184 | 185 | export function secondsSince(timestamp) { 186 | const then = new Date(timestamp); 187 | const now = new Date(); 188 | const diffInMs = now - then; 189 | return Math.floor(diffInMs / 1000); 190 | } 191 | 192 | export async function getCachedOrFetch(url, gzipped = false, cacheName = "assets") { 193 | try { 194 | // Open the cache 195 | const cache = await caches.open(cacheName); 196 | 197 | // Check if the asset is already cached 198 | const cachedResponse = await cache.match(url); 199 | 200 | if (cachedResponse) { 201 | console.log('Found in cache:', url); 202 | if (gzipped) { 203 | if (!("DecompressionStream" in window)) { 204 | throw new Error("DecompressionStream not supported in this browser."); 205 | } 206 | // Decompress stream and return as ArrayBuffer 207 | const decompressed = cachedResponse.body 208 | .pipeThrough(new DecompressionStream("gzip")); 209 | const decompressedBuffer = await new Response(decompressed).arrayBuffer(); 210 | return decompressedBuffer; 211 | } else { 212 | // Return ArrayBuffer from cached response 213 | return await cachedResponse.arrayBuffer(); 214 | } 215 | } 216 | 217 | // Not in cache, fetch from network 218 | console.log('Not in cache, fetching:', url); 219 | const response = await fetch(url); 220 | 221 | if (!response.ok) { 222 | throw new Error(`HTTP error! status: ${response.status}`); 223 | } 224 | 225 | // Clone the response since we need to use it twice 226 | // (once for cache, once for returning the ArrayBuffer) 227 | const responseToCache = response.clone(); 228 | 229 | // Store in cache for future use 230 | await cache.put(url, responseToCache); 231 | console.log('Stored in cache:', url); 232 | 233 | if (gzipped) { 234 | if (!("DecompressionStream" in window)) { 235 | throw new Error("DecompressionStream not supported in this browser."); 236 | } 237 | // Decompress stream and return as ArrayBuffer 238 | const decompressed = response.body 239 | .pipeThrough(new DecompressionStream("gzip")); 240 | const decompressedBuffer = await new Response(decompressed).arrayBuffer(); 241 | return decompressedBuffer; 242 | } else { 243 | // Return ArrayBuffer from the original response 244 | return await response.arrayBuffer(); 245 | } 246 | 247 | } catch (error) { 248 | console.error('Error in getCachedOrFetch:', error); 249 | throw error; 250 | } 251 | } 252 | 253 | export async function clearAllCache(cacheName = "assets") { 254 | const deleted = await caches.delete(cacheName); 255 | console.log('Deleted entire cache:', cacheName, deleted); 256 | return deleted; 257 | } 258 | 259 | let mouseDownOnBackdrop = false; // we can use singleton because modal is one! 260 | export function modalDialog(el) { 261 | el.addEventListener("mousedown", (e) => { 262 | mouseDownOnBackdrop = e.target === el; 263 | }); 264 | el.querySelectorAll('[data-action="close"]').forEach(closer => { 265 | closer.addEventListener("click", () => el.close()); 266 | }); 267 | el.addEventListener("click", (e) => { 268 | if (e.target === el && mouseDownOnBackdrop) { 269 | mouseDownOnBackdrop = false; 270 | el.close(); 271 | } 272 | }); 273 | return el; 274 | } 275 | 276 | let cacheFrame = null; 277 | export async function getBundle(name) { 278 | if (!document) { 279 | return null; 280 | } 281 | 282 | if (!cacheFrame) { 283 | cacheFrame = new Promise(resolve => { 284 | const el = document.createElement("iframe"); 285 | el.src = `${window.location.protocol}//${appHost()}/bundles`; 286 | el.style.display = "none"; 287 | el.onload = () => { 288 | resolve(el); 289 | }; 290 | document.body.appendChild(el); 291 | }); 292 | } 293 | 294 | const el = await cacheFrame; 295 | const channel = new MessageChannel(); 296 | el.contentWindow.postMessage({ type: "bundle", name: name, port: channel.port2 }, "*", [channel.port2]); 297 | return await new Promise((resolve, reject) => { 298 | channel.port1.onmessage = (e) => resolve(e.data.bundle); 299 | }); 300 | } 301 | 302 | export async function copyText(text) { 303 | try { 304 | await navigator.clipboard.writeText(text); 305 | return true; 306 | } catch { 307 | try { 308 | const ta = document.createElement("textarea"); 309 | ta.value = text; 310 | ta.setAttribute("readonly", ""); 311 | ta.style.position = "absolute"; 312 | ta.style.left = "-9999px"; 313 | document.body.appendChild(ta); 314 | ta.select(); 315 | const ok = document.execCommand("copy"); 316 | document.body.removeChild(ta); 317 | return ok; 318 | } catch { 319 | return false; 320 | } 321 | } 322 | } --------------------------------------------------------------------------------