1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
|
#!/bin/sh
# build-jupyterlab.sh
# installs jupyterlab and the full jupyter ecosystem via miniforge,
# then packages everything as a portable AppImage.
# usage:
# sh build-jupyterlab.sh
# JUPYTERLAB_VER=4.3.0 sh build-jupyterlab.sh # pin jupyterlab version
#
# running the output AppImage:
# regular: ./jupyterlab-VERSION-ARCH.AppImage lab
# notebook classic: ./jupyterlab-VERSION-ARCH.AppImage notebook
# any jupyter cmd: ./jupyterlab-VERSION-ARCH.AppImage nbconvert --to html notebook.ipynb
# docker/no-fuse: APPIMAGE_EXTRACT_AND_RUN=1 ./jupyterlab-VERSION-ARCH.AppImage lab
set -e
PKGNAME="jupyterlab"
# - set JUPYTERLAB_VER to pin a specific release, or leave empty for latest
JUPYTERLAB_VER="${JUPYTERLAB_VER:-}"
# - check required tools
for _cmd in curl bash; do
if ! command -v "$_cmd" > /dev/null 2>&1; then
printf 'error: required tool not found: %s\n' "$_cmd" >&2
exit 1
fi
done
# - detect host architecture
UNAME_ARCH="$(uname -m)"
case "$UNAME_ARCH" in
x86_64)
MINIFORGE_ARCH="x86_64"
;;
aarch64)
MINIFORGE_ARCH="aarch64"
;;
*)
printf 'error: unsupported architecture: %s\n' "$UNAME_ARCH" >&2
exit 1
;;
esac
# - miniforge latest release URL - always resolves to the most recent stable release
MINIFORGE_INSTALLER="Miniforge3-Linux-${MINIFORGE_ARCH}.sh"
MINIFORGE_URL="https://github.com/conda-forge/miniforge/releases/latest/download/${MINIFORGE_INSTALLER}"
APPIMAGETOOL_URL="https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${UNAME_ARCH}.AppImage"
OUTPUT_DIR="$(pwd)"
WORKDIR="$(mktemp -d)"
APPDIR="${WORKDIR}/AppDir"
CONDA_DIR="${APPDIR}/usr/lib/miniforge"
# - always clean up temp workdir on exit
cleanup() {
rm -rf "$WORKDIR"
}
trap cleanup EXIT INT TERM
printf '==> setting up build directory...\n'
mkdir -p \
"${APPDIR}/usr/bin" \
"${APPDIR}/usr/share/icons/hicolor/512x512/apps"
cd "$WORKDIR"
# - download miniforge installer (self-contained python + conda distribution)
printf '==> downloading miniforge (%s)...\n' "$MINIFORGE_ARCH"
curl -fL --progress-bar -o "${MINIFORGE_INSTALLER}" "${MINIFORGE_URL}"
# - install miniforge silently into AppDir - no PATH modifications on the host
printf '==> installing miniforge into AppDir...\n'
bash "${MINIFORGE_INSTALLER}" -b -p "${CONDA_DIR}"
# - install jupyterlab and the full jupyter ecosystem via pip
printf '==> installing jupyterlab and ecosystem packages...\n'
if [ -n "$JUPYTERLAB_VER" ]; then
_jl_spec="jupyterlab==${JUPYTERLAB_VER}"
else
_jl_spec="jupyterlab"
fi
"${CONDA_DIR}/bin/pip" install --no-cache-dir \
"${_jl_spec}" \
notebook \
nbclassic \
jupyter-console \
jupyter-server \
"nbconvert[all]" \
nbformat \
ipykernel \
ipython \
ipywidgets \
widgetsnbextension \
jupyterlab_widgets \
jupyterlab-git \
jupyter-lsp \
"python-lsp-server[all]" \
jupyterlab-lsp \
jupytext \
nbdime \
voila \
jupyterlab-rise \
jupyterlab_code_formatter \
black \
isort \
ruff \
jupyter-resource-usage
# - query the installed jupyterlab version for the output filename
PKGVER="$("${CONDA_DIR}/bin/python3" -c \
'import importlib.metadata; print(importlib.metadata.version("jupyterlab"))')"
printf '==> jupyterlab version: %s\n' "$PKGVER"
# - register the python kernel spec within the bundled environment
printf '==> registering python3 kernel...\n'
"${CONDA_DIR}/bin/python3" -m ipykernel install \
--sys-prefix \
--name "python3" \
--display-name "Python 3 (AppImage)"
# - patch kernel.json: replace the absolute python path with PATH-relative 'python3'
# - this makes the kernel portable across different AppImage mount paths at runtime
_kernel_json="${CONDA_DIR}/share/jupyter/kernels/python3/kernel.json"
"${CONDA_DIR}/bin/python3" -c "
import json, sys
path = sys.argv[1]
with open(path, 'r') as f:
spec = json.load(f)
spec['argv'][0] = 'python3'
with open(path, 'w') as f:
json.dump(spec, f, indent=1)
" "$_kernel_json"
# - enable nbdime git integration (best-effort - may fail if git is unavailable)
"${CONDA_DIR}/bin/nbdime" config-git --enable --global 2>/dev/null || true
# - patch python shebangs in all bundled scripts to use env-based lookup
# - jupyter dispatches subcommands (e.g. jupyter-notebook) by spawning them as
# new processes, which means their shebangs are executed by the kernel;
# the build-time absolute paths won't exist at runtime, so we replace them
# with '#!/usr/bin/env python3' - apprun exports PATH with the bundled python
# first, so env resolves to the correct interpreter in all child processes
printf '==> patching script shebangs for portability...\n'
find "${CONDA_DIR}/bin" -maxdepth 1 -type f | while IFS= read -r _f; do
case "$(head -1 "$_f" 2>/dev/null)" in
'#!'*python*)
sed -i "1s|^#!.*|#!/usr/bin/env python3|" "$_f"
;;
esac
done
# - clean conda/pip caches to reduce AppImage size
printf '==> cleaning caches to reduce AppImage size...\n'
"${CONDA_DIR}/bin/conda" clean --tarballs --index-cache --packages --yes 2>/dev/null || true
find "${CONDA_DIR}" -name '__pycache__' -type d -exec rm -rf '{}' + 2>/dev/null || true
find "${CONDA_DIR}" -name '*.pyc' -delete 2>/dev/null || true
# - AppRun is the entrypoint; resolves all paths relative to the AppImage mount
# - PYTHONHOME tells Python where to find its stdlib in the relocated environment
# - LD_LIBRARY_PATH ensures bundled shared libs are found before system ones
# - calling python3 directly with the jupyter script bypasses hardcoded shebangs
cat > "${APPDIR}/AppRun" << 'APPRUN_EOF'
#!/bin/sh
# apprun - entrypoint for jupyterlab AppImage
SELF="$(readlink -f "$0")"
HERE="$(dirname "$SELF")"
CONDA="${HERE}/usr/lib/miniforge"
_user_shell="$(awk -F: -v uid="$(id -u 2>/dev/null || echo '')" '$3==uid{print $7; exit}' /etc/passwd 2>/dev/null)"
[ -x "$_user_shell" ] || _user_shell="${SHELL:-}"
[ -x "$_user_shell" ] || _user_shell="/bin/bash"
[ -x "$_user_shell" ] || _user_shell="/bin/sh"
export SHELL="$_user_shell"
export PYTHONHOME="${CONDA}"
export PATH="${CONDA}/bin:${PATH}"
export TERMINFO_DIRS="${CONDA}/share/terminfo:/usr/share/terminfo:/lib/terminfo:/etc/terminfo"
exec "${CONDA}/bin/python3" "${CONDA}/bin/jupyter" "$@"
APPRUN_EOF
chmod +x "${APPDIR}/AppRun"
# - .desktop file is required by the AppImage spec
cat > "${APPDIR}/${PKGNAME}.desktop" << DESKTOP_EOF
[Desktop Entry]
Name=JupyterLab
Comment=JupyterLab - Next-Generation Notebook Interface
Exec=jupyterlab
Icon=jupyterlab
Type=Application
Categories=Development;Science;Education;
DESKTOP_EOF
# - fetch icon: prefer installed package assets, then a remote url,
# - then fall back to generating a minimal valid png using python stdlib
printf '==> fetching icon...\n'
ICON_PATH="${APPDIR}/usr/share/icons/hicolor/512x512/apps/${PKGNAME}.png"
# - find the largest png bundled with the jupyterlab package itself
_pkg_icon="$("${CONDA_DIR}/bin/python3" -c "
import os, jupyterlab
base = os.path.dirname(jupyterlab.__file__)
pngs = []
for root, dirs, files in os.walk(base):
for f in files:
if f.endswith('.png'):
p = os.path.join(root, f)
pngs.append((os.path.getsize(p), p))
pngs.sort(reverse=True)
print(pngs[0][1] if pngs else '')
" 2>/dev/null)"
if [ -n "$_pkg_icon" ]; then
cp "$_pkg_icon" "$ICON_PATH"
elif curl -fsSL \
-o "$ICON_PATH" \
"https://raw.githubusercontent.com/jupyter/design/master/logos/Square%20Logo/squarelogo-greytext-orangebody-greytextontop.png" \
2>/dev/null; then
printf ' icon downloaded from jupyter/design repo\n'
else
# - generate a 128x128 solid png in jupyter orange (#f37726) using only python stdlib
printf ' generating placeholder icon (jupyter orange)...\n'
"${CONDA_DIR}/bin/python3" -c "
import struct, zlib, sys
def chunk(name, data):
crc = zlib.crc32(name + data) & 0xffffffff
return struct.pack('>I', len(data)) + name + data + struct.pack('>I', crc)
w, h, r, g, b = 128, 128, 0xf3, 0x77, 0x26
raw = (b'\x00' + bytes([r, g, b] * w)) * h
png = (
b'\x89PNG\r\n\x1a\n'
+ chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0))
+ chunk(b'IDAT', zlib.compress(raw))
+ chunk(b'IEND', b'')
)
with open(sys.argv[1], 'wb') as f:
f.write(png)
" "$ICON_PATH"
fi
# - appimage spec also requires the icon at the AppDir root
cp "$ICON_PATH" "${APPDIR}/${PKGNAME}.png"
# - download appimagetool (itself an AppImage; APPIMAGE_EXTRACT_AND_RUN avoids FUSE dependency)
printf '==> downloading appimagetool (%s)...\n' "$UNAME_ARCH"
curl -fL --progress-bar -o "${WORKDIR}/appimagetool" "$APPIMAGETOOL_URL"
chmod +x "${WORKDIR}/appimagetool"
# - build the AppImage
# - APPIMAGE_EXTRACT_AND_RUN=1 lets appimagetool run without FUSE (required in Docker)
# - ARCH must be set so appimagetool embeds the correct ELF architecture tag
OUTPUT="${OUTPUT_DIR}/${PKGNAME}-${PKGVER}-${UNAME_ARCH}.AppImage"
printf '==> building AppImage...\n'
ARCH="$UNAME_ARCH" APPIMAGE_EXTRACT_AND_RUN=1 \
"${WORKDIR}/appimagetool" --comp gzip \
"${APPDIR}" "${OUTPUT}"
chmod +x "${OUTPUT}"
printf '\n==> done!\n'
printf ' output: %s\n\n' "$OUTPUT"
printf 'usage:\n'
printf ' regular system: ./%s-%s-%s.AppImage lab\n' "$PKGNAME" "$PKGVER" "$UNAME_ARCH"
printf ' docker/no-fuse: APPIMAGE_EXTRACT_AND_RUN=1 ./%s-%s-%s.AppImage lab\n' "$PKGNAME" "$PKGVER" "$UNAME_ARCH"
printf '\nmove %s to /opt/jupyterlab-appimage/ within your environment and use the jupyter wrapper script to run it\n\n' "$OUTPUT"
|