[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Support installing plugins from URL

Grace Kind (May 16, 2026, 2:24 PM -0500) ba8ff235 fa7f1a8f

+476 -4
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.14.36", 3 + "version": "0.14.37", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf build && NODE_ENV=development eleventy --serve",
+66
src/js/plugins/pluginService.js
··· 20 20 return params.has(DISABLE_PLUGINS_QUERY_PARAM); 21 21 } 22 22 23 + export function parseGithubRepoUrl(input) { 24 + if (typeof input !== "string") return null; 25 + const trimmed = input.trim(); 26 + if (!trimmed) return null; 27 + let url; 28 + try { 29 + url = new URL(trimmed); 30 + } catch { 31 + return null; 32 + } 33 + if (url.protocol !== "https:" && url.protocol !== "http:") { 34 + return null; 35 + } 36 + if (url.hostname !== "github.com" && url.hostname !== "www.github.com") { 37 + return null; 38 + } 39 + const segments = url.pathname.split("/").filter(Boolean); 40 + if (segments.length < 2) return null; 41 + const owner = segments[0]; 42 + const repo = segments[1].replace(/\.git$/, ""); 43 + if (!owner || !repo) return null; 44 + return `${owner}/${repo}`; 45 + } 46 + 23 47 export class PluginService extends EventEmitter { 24 48 constructor(preferencesProvider, session) { 25 49 super(); ··· 316 340 await this.prefManager.removeInstalledPlugin(pluginId); 317 341 throw e; 318 342 } 343 + } 344 + 345 + async installUnregisteredPlugin(url) { 346 + const repo = parseGithubRepoUrl(url); 347 + if (!repo) { 348 + throw new Error("Invalid GitHub URL"); 349 + } 350 + let manifest = null; 351 + try { 352 + manifest = await this.sourceProvider.getLiveManifestFromRepo(repo); 353 + } catch (e) { 354 + console.error("Failed to fetch manifest", e); 355 + throw new Error("Failed to fetch manifest"); 356 + } 357 + const { id, name, version, author, description } = manifest; 358 + if (await this.remoteRegistry.getListing(id)) { 359 + throw new Error(`Plugin ${id} is in the registry; install it from there`); 360 + } 361 + if (this.localRegistry && (await this.localRegistry.getListing(id))) { 362 + throw new Error(`Plugin ${id} is in the registry; install it from there`); 363 + } 364 + const installedPlugins = this.prefManager.getInstalledPlugins(); 365 + if (installedPlugins.some((plugin) => plugin.id === id)) { 366 + throw new Error(`Plugin ${id} already installed`); 367 + } 368 + await this.prefManager.addInstalledPlugin({ 369 + id, 370 + name, 371 + version, 372 + author, 373 + description, 374 + repo, 375 + enabled: true, 376 + }); 377 + try { 378 + await this.pluginBridge.loadPlugin(id, version, repo); 379 + } catch (e) { 380 + console.error(e); 381 + await this.prefManager.removeInstalledPlugin(id); 382 + throw e; 383 + } 384 + return { id, name }; 319 385 } 320 386 321 387 async uninstallPlugin(pluginId) {
+11
src/js/plugins/sourceProvider.js
··· 53 53 return parsePluginManifest(pluginId, await response.json()); 54 54 } 55 55 56 + async getLiveManifestFromRepo(repo) { 57 + if (!repo) { 58 + throw new Error("Repo is required"); 59 + } 60 + const url = remoteAssetUrl(repo, "main", "manifest.json"); 61 + const response = await fetch(url, { cache: "no-store" }); 62 + if (!response.ok) throw new Error(`HTTP ${response.status}`); 63 + const manifest = await response.json(); 64 + return parsePluginManifest(manifest.id, manifest); 65 + } 66 + 56 67 async getSource(pluginId, version, repo) { 57 68 if (pluginId.endsWith("__LOCAL")) { 58 69 const response = await fetch(`/plugins-local/${pluginId}/main.js`);
+71 -1
src/js/views/settings/advanced.view.js
··· 11 11 CUSTOM_APP_VIEW_CONFIG_ID, 12 12 } from "/js/appViewConfig.js"; 13 13 import { alertIconTemplate } from "/js/templates/icons/alertIcon.template.js"; 14 + import { showToast } from "/js/toasts.js"; 14 15 15 16 class SettingsAdvancedView extends View { 16 17 async render({ ··· 36 37 ? storedConfig.appViewServiceDid 37 38 : "", 38 39 customChatServiceDid: isStoredCustom ? storedConfig.chatServiceDid : "", 40 + pluginInstallLoading: false, 39 41 }; 40 42 41 43 function resolveSelectedAppViewConfig() { ··· 94 96 renderPage(); 95 97 } 96 98 99 + async function handleInstallPlugin(e) { 100 + e.preventDefault(); 101 + const input = e.target.elements.pluginUrl; 102 + const url = input.value.trim(); 103 + if (!url) return; 104 + state.pluginInstallLoading = true; 105 + renderPage(); 106 + try { 107 + const { name } = await pluginService.installUnregisteredPlugin(url); 108 + input.value = ""; 109 + showToast(`Installed ${name}`, { style: "success" }); 110 + } catch (error) { 111 + showToast(error?.message ?? "Failed to install plugin", { 112 + style: "error", 113 + }); 114 + } finally { 115 + state.pluginInstallLoading = false; 116 + renderPage(); 117 + } 118 + } 119 + 97 120 function renderPage() { 98 121 const currentUser = dataLayer.selectors.getCurrentUser(); 99 122 const numNotifications = ··· 159 182 </div> 160 183 ${isCustom 161 184 ? html` 162 - <div class="warning-area"> 185 + <div 186 + class="warning-area" 187 + data-testid="custom-appview-warning" 188 + > 163 189 <h4>${alertIconTemplate()} Warning</h4> 164 190 Only set these values if you know what they mean! 165 191 </div> ··· 215 241 ${state.errorMessage} 216 242 </div>` 217 243 : ""} 244 + </div> 245 + </section> 246 + </form> 247 + <form 248 + id="install-unregistered-plugin-form" 249 + @submit=${(e) => handleInstallPlugin(e)} 250 + > 251 + <section class="settings-section"> 252 + <h2>Install plugin from URL</h2> 253 + <p> 254 + Install a plugin directly from a public GitHub repository. 255 + The repo must contain a valid manifest.json on its main 256 + branch. 257 + </p> 258 + <div class="warning-area"> 259 + <h4>${alertIconTemplate()} Warning</h4> 260 + Unregistered plugins have not been reviewed. Only install 261 + plugins from sources you trust. 262 + </div> 263 + <div class="form-group"> 264 + <label for="pluginUrl">GitHub repo URL</label> 265 + <input 266 + id="pluginUrl" 267 + name="pluginUrl" 268 + type="url" 269 + placeholder="https://github.com/owner/repo" 270 + required 271 + autocorrect="off" 272 + autocapitalize="off" 273 + spellcheck="false" 274 + data-testid="install-unregistered-plugin-input" 275 + /> 276 + </div> 277 + <div class="button-group"> 278 + <button 279 + type="submit" 280 + data-testid="install-unregistered-plugin-submit" 281 + ?disabled=${state.pluginInstallLoading} 282 + > 283 + ${state.pluginInstallLoading 284 + ? html`Installing 285 + <div class="loading-spinner"></div>` 286 + : "Install"} 287 + </button> 218 288 </div> 219 289 </section> 220 290 </form>
+61
tests/e2e/specs/flows/installUnregisteredPlugin.test.js
··· 1 + import { test, expect } from "../../base.js"; 2 + import { login } from "../../helpers.js"; 3 + import { MockServer } from "../../mockServer.js"; 4 + 5 + const PLUGIN_ID = "unregistered-themes"; 6 + const PLUGIN_NAME = "Unregistered Themes"; 7 + const REPO_URL = "https://github.com/alice/unregistered-themes"; 8 + 9 + test.describe("Unregistered plugin install flow", () => { 10 + test("installing from advanced settings shows the plugin on the plugins view", async ({ 11 + page, 12 + }) => { 13 + const mockServer = new MockServer(); 14 + mockServer.registryEntries = []; 15 + mockServer.liveManifest = { 16 + id: PLUGIN_ID, 17 + name: PLUGIN_NAME, 18 + version: "1.0.0", 19 + author: "alice", 20 + description: "Adds extra themes", 21 + }; 22 + await mockServer.setup(page); 23 + // The shared versioned-manifest route falls back to "remote-plugin" when 24 + // the registry is empty; override it so the load step gets a manifest 25 + // whose id matches what we installed. 26 + await page.route( 27 + "**/raw.githubusercontent.com/alice/unregistered-themes/1.0.0/manifest.json", 28 + (route) => 29 + route.fulfill({ 30 + status: 200, 31 + contentType: "application/json", 32 + body: JSON.stringify(mockServer.liveManifest), 33 + }), 34 + ); 35 + await login(page); 36 + 37 + await page.goto("/settings/advanced"); 38 + const urlInput = page.locator( 39 + '[data-testid="install-unregistered-plugin-input"]', 40 + ); 41 + await expect(urlInput).toBeVisible(); 42 + await urlInput.fill(REPO_URL); 43 + 44 + const putPrefs = page.waitForResponse((res) => 45 + res.url().includes("app.bsky.actor.putPreferences"), 46 + ); 47 + await page 48 + .locator('[data-testid="install-unregistered-plugin-submit"]') 49 + .click(); 50 + await putPrefs; 51 + await expect(page.locator('[data-testid="toast"]')).toContainText( 52 + `Installed ${PLUGIN_NAME}`, 53 + ); 54 + 55 + await page.goto("/settings/plugins"); 56 + const plugins = page.locator("#settings-plugins-view"); 57 + const item = plugins.locator(".plugin-list-item", { hasText: PLUGIN_NAME }); 58 + await expect(item).toBeVisible({ timeout: 10000 }); 59 + await expect(item.locator(".plugin-toggle")).toHaveAttribute("checked", ""); 60 + }); 61 + });
+80 -1
tests/e2e/specs/views/settings/advanced.view.test.js
··· 59 59 await select.selectOption("custom"); 60 60 await expect(view.locator('input[name="appViewServiceDid"]')).toBeVisible(); 61 61 await expect(view.locator('input[name="chatServiceDid"]')).toBeVisible(); 62 - await expect(view.locator(".warning-area")).toBeVisible(); 62 + await expect( 63 + view.locator('[data-testid="custom-appview-warning"]'), 64 + ).toBeVisible(); 63 65 64 66 await select.selectOption("bluesky"); 65 67 await expect(view.locator('input[name="appViewServiceDid"]')).toHaveCount( ··· 203 205 ); 204 206 expect(storedConfig).not.toBeNull(); 205 207 expect(JSON.parse(storedConfig).id).toBe("blacksky"); 208 + }); 209 + 210 + test.describe("Install plugin from URL section", () => { 211 + test("renders the URL input and submit button", async ({ page }) => { 212 + const mockServer = new MockServer(); 213 + await mockServer.setup(page); 214 + 215 + await login(page); 216 + await page.goto("/settings/advanced"); 217 + 218 + const view = page.locator("#settings-advanced-view"); 219 + await expect(view).toContainText("Install plugin from URL", { 220 + timeout: 10000, 221 + }); 222 + await expect( 223 + view.locator('[data-testid="install-unregistered-plugin-input"]'), 224 + ).toBeVisible(); 225 + await expect( 226 + view.locator('[data-testid="install-unregistered-plugin-submit"]'), 227 + ).toBeVisible(); 228 + }); 229 + 230 + test("shows an error toast when the URL is not a GitHub URL", async ({ 231 + page, 232 + }) => { 233 + const mockServer = new MockServer(); 234 + await mockServer.setup(page); 235 + 236 + await login(page); 237 + await page.goto("/settings/advanced"); 238 + 239 + const view = page.locator("#settings-advanced-view"); 240 + await view 241 + .locator('[data-testid="install-unregistered-plugin-input"]') 242 + .fill("https://example.com/owner/repo"); 243 + await view 244 + .locator('[data-testid="install-unregistered-plugin-submit"]') 245 + .click(); 246 + 247 + await expect(page.locator('[data-testid="toast"]')).toContainText( 248 + "Invalid GitHub URL", 249 + ); 250 + }); 251 + 252 + test("shows an error toast when the plugin id is already in the registry", async ({ 253 + page, 254 + }) => { 255 + const mockServer = new MockServer(); 256 + mockServer.registryEntries = [ 257 + { 258 + id: "remote-plugin", 259 + name: "Remote Plugin", 260 + repo: "alice/remote-plugin", 261 + }, 262 + ]; 263 + mockServer.liveManifest = { 264 + id: "remote-plugin", 265 + name: "Remote Plugin", 266 + version: "1.0.0", 267 + }; 268 + await mockServer.setup(page); 269 + 270 + await login(page); 271 + await page.goto("/settings/advanced"); 272 + 273 + const view = page.locator("#settings-advanced-view"); 274 + await view 275 + .locator('[data-testid="install-unregistered-plugin-input"]') 276 + .fill("https://github.com/alice/remote-plugin"); 277 + await view 278 + .locator('[data-testid="install-unregistered-plugin-submit"]') 279 + .click(); 280 + 281 + await expect(page.locator('[data-testid="toast"]')).toContainText( 282 + "in the registry", 283 + ); 284 + }); 206 285 }); 207 286 208 287 test.describe("Logged-out behavior", () => {
+186 -1
tests/unit/specs/pluginService.test.js
··· 45 45 remoteListings = [], 46 46 localListings = null, 47 47 liveManifests = {}, 48 + liveManifestsByRepo = {}, 48 49 } = {}) { 49 50 const { state, provider } = makeProvider(); 50 51 const service = new PluginService(provider, null); ··· 75 76 }; 76 77 service.localPluginsEnabled = localListings != null; 77 78 service.localRegistry = localListings 78 - ? { getListings: async () => localListings } 79 + ? { 80 + getListings: async () => localListings, 81 + getListing: async (id) => 82 + localListings.find((listing) => listing.id === id) ?? null, 83 + } 79 84 : null; 80 85 service.sourceProvider = { 81 86 getLiveManifest: async (id) => { 82 87 if (!liveManifests[id]) throw new Error(`no manifest for ${id}`); 83 88 return liveManifests[id]; 89 + }, 90 + getLiveManifestFromRepo: async (repo) => { 91 + if (!liveManifestsByRepo[repo]) { 92 + throw new Error(`no manifest for ${repo}`); 93 + } 94 + return liveManifestsByRepo[repo]; 84 95 }, 85 96 getCacheUrls: async (id, version, repo) => [ 86 97 `https://cache.test/${id}/${version}/${repo}`, ··· 601 612 const listings = await service.listRegistryPlugins(); 602 613 assertEquals(listings.length, 1); 603 614 assertEquals(listings[0].id, "alpha"); 615 + }); 616 + }); 617 + 618 + t.describe("installUnregisteredPlugin", (it) => { 619 + it("installs from a github.com URL using manifest metadata", async () => { 620 + const { service, state, loadCalls } = makeService({ 621 + liveManifestsByRepo: { 622 + "ow/alpha": { 623 + id: "alpha", 624 + name: "Alpha", 625 + version: "1.0.0", 626 + author: "ow", 627 + description: "the first", 628 + }, 629 + }, 630 + }); 631 + const result = await service.installUnregisteredPlugin( 632 + "https://github.com/ow/alpha", 633 + ); 634 + assertEquals(result, { id: "alpha", name: "Alpha" }); 635 + assertEquals(state.installedPlugins, [ 636 + { 637 + id: "alpha", 638 + name: "Alpha", 639 + version: "1.0.0", 640 + author: "ow", 641 + description: "the first", 642 + repo: "ow/alpha", 643 + enabled: true, 644 + }, 645 + ]); 646 + assertEquals(loadCalls, [ 647 + { id: "alpha", version: "1.0.0", repo: "ow/alpha" }, 648 + ]); 649 + }); 650 + 651 + it("strips .git and extra path segments from the URL", async () => { 652 + const { service, state } = makeService({ 653 + liveManifestsByRepo: { 654 + "ow/alpha": { id: "alpha", name: "Alpha", version: "1.0.0" }, 655 + }, 656 + }); 657 + await service.installUnregisteredPlugin( 658 + "https://github.com/ow/alpha.git/tree/main", 659 + ); 660 + assertEquals(state.installedPlugins[0].repo, "ow/alpha"); 661 + }); 662 + 663 + it("rejects non-GitHub URLs", async () => { 664 + const { service, state } = makeService(); 665 + let caught = null; 666 + try { 667 + await service.installUnregisteredPlugin("https://example.com/ow/alpha"); 668 + } catch (error) { 669 + caught = error; 670 + } 671 + assert(caught?.message.includes("Invalid GitHub URL")); 672 + assertEquals(state.installedPlugins, []); 673 + }); 674 + 675 + it("rejects malformed URL strings", async () => { 676 + const { service } = makeService(); 677 + let caught = null; 678 + try { 679 + await service.installUnregisteredPlugin("not a url"); 680 + } catch (error) { 681 + caught = error; 682 + } 683 + assert(caught?.message.includes("Invalid GitHub URL")); 684 + }); 685 + 686 + it("throws when manifest is missing required fields", async () => { 687 + const { service, state } = makeService(); 688 + service.sourceProvider.getLiveManifestFromRepo = async () => { 689 + throw new Error('missing required field "version"'); 690 + }; 691 + let caught = null; 692 + try { 693 + await service.installUnregisteredPlugin("https://github.com/ow/alpha"); 694 + } catch (error) { 695 + caught = error; 696 + } 697 + assert(caught?.message.includes("Failed to fetch manifest")); 698 + assertEquals(state.installedPlugins, []); 699 + }); 700 + 701 + it("rejects when the plugin id is already installed", async () => { 702 + const { service, state } = makeService({ 703 + liveManifestsByRepo: { 704 + "ow/alpha": { id: "alpha", name: "Alpha", version: "1.0.0" }, 705 + }, 706 + }); 707 + state.installedPlugins = [ 708 + { 709 + id: "alpha", 710 + name: "Alpha", 711 + version: "0.9.0", 712 + repo: "ow/alpha", 713 + enabled: true, 714 + }, 715 + ]; 716 + let caught = null; 717 + try { 718 + await service.installUnregisteredPlugin("https://github.com/ow/alpha"); 719 + } catch (error) { 720 + caught = error; 721 + } 722 + assert(caught?.message.includes("already installed")); 723 + assertEquals(state.installedPlugins.length, 1); 724 + }); 725 + 726 + it("rolls back the preference entry when load fails", async () => { 727 + const { service, state } = makeService({ 728 + liveManifestsByRepo: { 729 + "ow/alpha": { id: "alpha", name: "Alpha", version: "1.0.0" }, 730 + }, 731 + }); 732 + service.pluginBridge.loadPlugin = async () => { 733 + throw new Error("boom"); 734 + }; 735 + let caught = null; 736 + try { 737 + await service.installUnregisteredPlugin("https://github.com/ow/alpha"); 738 + } catch (error) { 739 + caught = error; 740 + } 741 + assert(caught?.message.includes("boom")); 742 + assertEquals(state.installedPlugins, []); 743 + }); 744 + 745 + it("rejects when the plugin id is already in the remote registry", async () => { 746 + const { service, state } = makeService({ 747 + remoteListings: [{ id: "alpha", repo: "ow/alpha" }], 748 + liveManifestsByRepo: { 749 + "someone/alpha-fork": { 750 + id: "alpha", 751 + name: "Alpha Fork", 752 + version: "1.0.0", 753 + }, 754 + }, 755 + }); 756 + let caught = null; 757 + try { 758 + await service.installUnregisteredPlugin( 759 + "https://github.com/someone/alpha-fork", 760 + ); 761 + } catch (error) { 762 + caught = error; 763 + } 764 + assert(caught?.message.includes("in the registry")); 765 + assertEquals(state.installedPlugins, []); 766 + }); 767 + 768 + it("rejects when the plugin id is in the local registry", async () => { 769 + const { service, state } = makeService({ 770 + localListings: [{ id: "alpha", repo: "ow/alpha-local" }], 771 + liveManifestsByRepo: { 772 + "someone/alpha-fork": { 773 + id: "alpha", 774 + name: "Alpha Fork", 775 + version: "1.0.0", 776 + }, 777 + }, 778 + }); 779 + let caught = null; 780 + try { 781 + await service.installUnregisteredPlugin( 782 + "https://github.com/someone/alpha-fork", 783 + ); 784 + } catch (error) { 785 + caught = error; 786 + } 787 + assert(caught?.message.includes("in the registry")); 788 + assertEquals(state.installedPlugins, []); 604 789 }); 605 790 }); 606 791