Merge pull request 'init webview: add webview ui and list machine as api example' (#1365) from feat/ui into main

Reviewed-on: clan/clan-core#1365
This commit is contained in:
hsjobeki 2024-05-18 14:14:12 +00:00
commit 5863ddca0e
30 changed files with 4020 additions and 12 deletions

8
.gitignore vendored
View File

@ -13,6 +13,9 @@ nixos.qcow2
**/*.glade~
/docs/out
# dream2nix
.dream2nix
# python
__pycache__
.coverage
@ -28,3 +31,8 @@ build
build-dir
repo
.env
# node
node_modules
dist
.webui

View File

@ -20,6 +20,28 @@
"type": "github"
}
},
"dream2nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"purescript-overlay": "purescript-overlay",
"pyproject-nix": "pyproject-nix"
},
"locked": {
"lastModified": 1715711628,
"narHash": "sha256-MwkdhFpFBABp6IZWy/A2IwDe5Y1z0qZXInTO6AtvGZY=",
"owner": "nix-community",
"repo": "dream2nix",
"rev": "995e831dac8c2c843f1289d15dfec526cb84afdd",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "dream2nix",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
@ -129,9 +151,49 @@
"type": "github"
}
},
"purescript-overlay": {
"inputs": {
"nixpkgs": [
"dream2nix",
"nixpkgs"
],
"slimlock": "slimlock"
},
"locked": {
"lastModified": 1696022621,
"narHash": "sha256-eMjFmsj2G1E0Q5XiibUNgFjTiSz0GxIeSSzzVdoN730=",
"owner": "thomashoneyman",
"repo": "purescript-overlay",
"rev": "047c7933abd6da8aa239904422e22d190ce55ead",
"type": "github"
},
"original": {
"owner": "thomashoneyman",
"repo": "purescript-overlay",
"type": "github"
}
},
"pyproject-nix": {
"flake": false,
"locked": {
"lastModified": 1702448246,
"narHash": "sha256-hFg5s/hoJFv7tDpiGvEvXP0UfFvFEDgTdyHIjDVHu1I=",
"owner": "davhau",
"repo": "pyproject.nix",
"rev": "5a06a2697b228c04dd2f35659b4b659ca74f7aeb",
"type": "github"
},
"original": {
"owner": "davhau",
"ref": "dream2nix",
"repo": "pyproject.nix",
"type": "github"
}
},
"root": {
"inputs": {
"disko": "disko",
"dream2nix": "dream2nix",
"flake-parts": "flake-parts",
"nixos-generators": "nixos-generators",
"nixos-images": "nixos-images",
@ -140,6 +202,28 @@
"treefmt-nix": "treefmt-nix"
}
},
"slimlock": {
"inputs": {
"nixpkgs": [
"dream2nix",
"purescript-overlay",
"nixpkgs"
]
},
"locked": {
"lastModified": 1688610262,
"narHash": "sha256-Wg0ViDotFWGWqKIQzyYCgayeH8s4U1OZcTiWTQYdAp4=",
"owner": "thomashoneyman",
"repo": "slimlock",
"rev": "b5c6cdcaf636ebbebd0a1f32520929394493f1a6",
"type": "github"
},
"original": {
"owner": "thomashoneyman",
"repo": "slimlock",
"type": "github"
}
},
"sops-nix": {
"inputs": {
"nixpkgs": [

View File

@ -21,6 +21,8 @@
flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs";
treefmt-nix.url = "github:numtide/treefmt-nix";
treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
dream2nix.url = "github:nix-community/dream2nix";
dream2nix.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =

View File

@ -11,7 +11,9 @@
treefmt.programs.mypy.directories = {
"pkgs/clan-cli".extraPythonPackages = self'.packages.clan-cli.testDependencies;
"pkgs/clan-vm-manager".extraPythonPackages =
self'.packages.clan-vm-manager.externalTestDeps ++ self'.packages.clan-cli.testDependencies;
# clan-vm-manager currently only exists on linux
(self'.packages.clan-vm-manager.externalTestDeps or [ ])
++ self'.packages.clan-cli.testDependencies;
};
treefmt.settings.formatter.nix = {

View File

@ -106,7 +106,7 @@ class MainApplication(Adw.Application):
def on_activate(self, source: "MainApplication") -> None:
if not self.window:
self.init_style()
self.window = MainWindow(config=ClanConfig(initial_view="list"))
self.window = MainWindow(config=ClanConfig(initial_view="webview"))
self.window.set_application(self)
self.window.show()

View File

@ -0,0 +1,125 @@
import dataclasses
import json
import sys
import threading
from collections.abc import Callable
from pathlib import Path
from typing import Any, Union
import gi
gi.require_version("WebKit", "6.0")
from gi.repository import GLib, WebKit
site_index: Path = (
Path(sys.argv[0]).absolute()
/ Path("../..")
/ Path("clan_vm_manager/.webui/index.html")
).resolve()
def type_to_dict(t: Any) -> dict:
if dataclasses.is_dataclass(t):
fields = dataclasses.fields(t)
return {
"type": "dataclass",
"name": t.__name__,
"fields": {f.name: type_to_dict(f.type) for f in fields},
}
if hasattr(t, "__origin__"): # Check if it's a generic type
if t.__origin__ is None:
# Non-generic user-defined or built-in type
return {"type": t.__name__}
if t.__origin__ is Union:
return {"type": "union", "of": [type_to_dict(arg) for arg in t.__args__]}
elif issubclass(t.__origin__, list):
return {"type": "list", "item_type": type_to_dict(t.__args__[0])}
elif issubclass(t.__origin__, dict):
return {
"type": "dict",
"key_type": type_to_dict(t.__args__[0]),
"value_type": type_to_dict(t.__args__[1]),
}
elif issubclass(t.__origin__, tuple):
return {
"type": "tuple",
"element_types": [type_to_dict(elem) for elem in t.__args__],
}
elif issubclass(t.__origin__, set):
return {"type": "set", "item_type": type_to_dict(t.__args__[0])}
else:
# Handle other generic types (like Union, Optional)
return {
"type": str(t.__origin__.__name__),
"parameters": [type_to_dict(arg) for arg in t.__args__],
}
elif isinstance(t, type):
return {"type": t.__name__}
else:
return {"type": str(t)}
class WebView:
def __init__(self) -> None:
self.method_registry: dict[str, Callable] = {}
self.webview = WebKit.WebView()
self.manager = self.webview.get_user_content_manager()
# Can be called with: window.webkit.messageHandlers.gtk.postMessage("...")
# Important: it seems postMessage must be given some payload, otherwise it won't trigger the event
self.manager.register_script_message_handler("gtk")
self.manager.connect("script-message-received", self.on_message_received)
self.webview.load_uri(f"file://{site_index}")
def method(self, function: Callable) -> Callable:
# type_hints = get_type_hints(function)
# serialized_hints = {key: type_to_dict(value) for key, value in type_hints.items()}
self.method_registry[function.__name__] = function
return function
def on_message_received(
self, user_content_manager: WebKit.UserContentManager, message: Any
) -> None:
payload = json.loads(message.to_json(0))
print(f"Received message: {payload}")
method_name = payload["method"]
handler_fn = self.method_registry[method_name]
# Start handler_fn in a new thread
# GLib.idle_add(handler_fn)
thread = threading.Thread(
target=self.threaded_handler,
args=(handler_fn, payload.get("data"), method_name),
)
thread.start()
def threaded_handler(
self, handler_fn: Callable[[Any], Any], data: Any, method_name: str
) -> None:
result = handler_fn(data)
serialized = json.dumps(result)
# Use idle_add to queue the response call to js on the main GTK thread
GLib.idle_add(self.call_js, method_name, serialized)
def call_js(self, method_name: str, serialized: str) -> bool:
# This function must be run on the main GTK thread to interact with the webview
# result = method_fn(data) # takes very long
# serialized = result
self.webview.evaluate_javascript(
f"""
window.clan.{method_name}(`{serialized}`);
""",
-1,
None,
None,
None,
)
return False # Important to return False so that it's not run again
def get_webview(self) -> WebKit.WebView:
return self.webview

View File

@ -2,6 +2,7 @@ import logging
import threading
import gi
from clan_cli import machines
from clan_cli.history.list import list_history
from clan_vm_manager.components.interfaces import ClanConfig
@ -11,6 +12,7 @@ from clan_vm_manager.singletons.use_vms import ClanStore
from clan_vm_manager.views.details import Details
from clan_vm_manager.views.list import ClanList
from clan_vm_manager.views.logs import Logs
from clan_vm_manager.views.webview import WebView
gi.require_version("Adw", "1")
@ -59,6 +61,14 @@ class MainWindow(Adw.ApplicationWindow):
stack_view.add_named(Details(), "details")
stack_view.add_named(Logs(), "logs")
webview = WebView()
@webview.method
def list_machines(data: None) -> list[str]:
return machines.list.list_machines(".")
stack_view.add_named(webview.get_webview(), "webview")
stack_view.set_visible_child_name(config.initial_view)
view.set_content(scroll)

View File

@ -12,11 +12,14 @@
clan-cli,
makeDesktopItem,
libadwaita,
webkitgtk_6_0,
pytest, # Testing framework
pytest-cov, # Generate coverage reports
pytest-subprocess, # fake the real subprocess behavior to make your tests more independent.
pytest-xdist, # Run tests in parallel on multiple cores
pytest-timeout, # Add timeouts to your tests
webview-ui,
fontconfig,
}:
let
source = ./.;
@ -35,6 +38,7 @@ let
pygobject-stubs
gtk4
libadwaita
webkitgtk_6_0
gnome.adwaita-icon-theme
];
@ -42,7 +46,9 @@ let
allPythonDeps = [ (python3.pkgs.toPythonModule clan-cli) ] ++ externalPythonDeps;
# Runtime binary dependencies required by the application
runtimeDependencies = [ ];
runtimeDependencies = [
];
# Dependencies required for running tests
externalTestDeps =
@ -68,6 +74,7 @@ python3.pkgs.buildPythonApplication rec {
format = "pyproject";
makeWrapperArgs = [
"--set FONTCONFIG_FILE ${fontconfig.out}/etc/fonts/fonts.conf"
# This prevents problems with mixed glibc versions that might occur when the
# cli is called through a browser built against another glibc
"--unset LD_LIBRARY_PATH"
@ -78,6 +85,7 @@ python3.pkgs.buildPythonApplication rec {
setuptools
copyDesktopItems
wrapGAppsHook
gobject-introspection
];
@ -98,6 +106,19 @@ python3.pkgs.buildPythonApplication rec {
chmod +w -R ./src
cd ./src
export FONTCONFIG_FILE=${fontconfig.out}/etc/fonts/fonts.conf
export FONTCONFIG_PATH=${fontconfig.out}/etc/fonts
mkdir -p .home/.local/share/fonts
export HOME=.home
fc-cache --verbose
# > fc-cache succeded
echo "Loaded the following fonts ..."
fc-list
echo "STARTING ..."
export NIX_STATE_DIR=$TMPDIR/nix IN_NIX_SANDBOX=1
${pythonWithTestDeps}/bin/python -m pytest -s -m "not impure" ./tests
touch $out
@ -121,12 +142,30 @@ python3.pkgs.buildPythonApplication rec {
passthru.runtimeDependencies = runtimeDependencies;
passthru.testDependencies = testDependencies;
# TODO: place webui in lib/python3.11/site-packages/clan_vm_manager
postInstall = ''
mkdir -p $out/clan_vm_manager/.webui
cp -r ${webview-ui}/dist/* $out/clan_vm_manager/.webui
'';
# Don't leak python packages into a devshell.
# It can be very confusing if you `nix run` than load the cli from the devshell instead.
postFixup = ''
rm $out/nix-support/propagated-build-inputs
'';
checkPhase = ''
export FONTCONFIG_FILE=${fontconfig.out}/etc/fonts/fonts.conf
export FONTCONFIG_PATH=${fontconfig.out}/etc/fonts
mkdir -p .home/.local/share/fonts
export HOME=.home
fc-cache --verbose
# > fc-cache succeded
echo "Loaded the following fonts ..."
fc-list
PYTHONPATH= $out/bin/clan-vm-manager --help
'';
desktopItems = [ desktop-file ];

View File

@ -1,15 +1,24 @@
{ ... }:
{
perSystem =
{ config, pkgs, ... }:
{
devShells.clan-vm-manager = pkgs.callPackage ./shell.nix {
inherit (config.packages) clan-vm-manager;
};
packages.clan-vm-manager = pkgs.python3.pkgs.callPackage ./default.nix {
inherit (config.packages) clan-cli;
};
config,
pkgs,
lib,
system,
...
}:
if lib.elem system lib.platforms.darwin then
{ }
else
{
devShells.clan-vm-manager = pkgs.callPackage ./shell.nix {
inherit (config.packages) clan-vm-manager webview-ui;
};
packages.clan-vm-manager = pkgs.python3.pkgs.callPackage ./default.nix {
inherit (config.packages) clan-cli webview-ui;
};
checks = config.packages.clan-vm-manager.tests;
};
checks = config.packages.clan-vm-manager.tests;
};
}

View File

@ -0,0 +1,7 @@
# Webkit GTK doesn't interop flawless with Solid.js build result
1. Webkit expects script tag to be in `body` only solid.js puts the in the head.
2. script and css files are loaded with type="module" and crossorigin tags beeing set. WebKit silently fails to load then.
3. Paths to resiources are not allowed to start with "/" because webkit interprets them relative to the system and not the base url.
4. webkit doesn't support native features such as directly handling external urls (i.e opening them in the default browser)
6. Other problems to be found?

View File

@ -10,6 +10,7 @@
python3,
gtk4,
libadwaita,
webview-ui,
}:
let
@ -51,5 +52,11 @@ mkShell {
# Add clan-cli to the python path so that we can import it without building it in nix first
export PYTHONPATH="$GIT_ROOT/pkgs/clan-cli":"$PYTHONPATH"
# Add the webview-ui to the .webui directory
rm -rf ./clan_vm_manager/.webui/*
mkdir -p ./clan_vm_manager/.webui
cp -a ${webview-ui}/dist/* ./clan_vm_manager/.webui
chmod -R +w ./clan_vm_manager/.webui
'';
}

View File

@ -6,6 +6,7 @@
./clan-vm-manager/flake-module.nix
./installer/flake-module.nix
./schemas/flake-module.nix
./webview-ui/flake-module.nix
];
perSystem =

6
pkgs/webview-ui/.envrc Normal file
View File

@ -0,0 +1,6 @@
source_up
watch_file flake-module.nix default.nix
# Because we depend on nixpkgs sources, uploading to builders takes a long time
use flake .#webview-ui --builders ''

View File

@ -0,0 +1,34 @@
## Usage
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev` or `npm start`
Runs the app in the development mode.<br>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)

View File

@ -0,0 +1,57 @@
const fs = require("fs");
const path = require("path");
const distPath = path.resolve(__dirname, "dist");
const manifestPath = path.join(distPath, ".vite/manifest.json");
const outputPath = path.join(distPath, "index.html");
fs.readFile(manifestPath, { encoding: "utf8" }, (err, data) => {
if (err) {
return console.error("Failed to read manifest:", err);
}
const manifest = JSON.parse(data);
/** @type {{ file: string; name: string; src: string; isEntry: bool; css: string[]; } []} */
const assets = Object.values(manifest);
console.log(`Generate custom index.html from ${manifestPath} ...`);
// Start with a basic HTML structure
let htmlContent = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Webview UI</title>`;
// Add linked stylesheets
assets.forEach((asset) => {
asset.css.forEach((cssEntry) => {
htmlContent += `\n <link rel="stylesheet" href="${cssEntry}">`;
});
});
htmlContent += `
</head>
<body>
<div id="app"></div>
`;
// Add scripts
assets.forEach((asset) => {
if (asset.file.endsWith(".js")) {
htmlContent += `\n <script src="${asset.file}"></script>`;
}
});
htmlContent += `
</body>
</html>`;
// Write the HTML file
fs.writeFile(outputPath, htmlContent, (err) => {
if (err) {
console.error("Failed to write custom index.html:", err);
} else {
console.log("Custom index.html generated successfully!");
}
});
});

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Solid App</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
</head>
<body>
<div id="app"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

3320
pkgs/webview-ui/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
{
"name": "@clan/webview-ui",
"version": "0.0.1",
"description": "",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vite build && npm run convert-html",
"convert-html": "node gtk.webview.js",
"serve": "vite preview"
},
"license": "MIT",
"devDependencies": {
"autoprefixer": "^10.4.19",
"daisyui": "^4.11.1",
"postcss": "^8.4.38",
"solid-devtools": "^0.29.2",
"tailwindcss": "^3.4.3",
"typescript": "^5.3.3",
"vite-plugin-solid": "^2.8.2",
"vite": "^5.0.11"
},
"dependencies": {
"solid-js": "^1.8.11"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,36 @@
import { Match, Switch, createSignal, type Component } from "solid-js";
import { CountProvider } from "./Config";
import { Nested } from "./nested";
type Route = "home" | "graph";
const App: Component = () => {
const [route, setRoute] = createSignal<Route>("home");
return (
<CountProvider>
<div class="w-full flex items-center flex-col gap-2 my-2">
<div>Clan</div>
<p>Current route: {route()}</p>
<div class="flex items-center">
<button
onClick={() => setRoute((o) => (o === "graph" ? "home" : "graph"))}
class="btn btn-link"
>
Navigate to {route() === "home" ? "graph" : "home"}
</button>
</div>
<Switch fallback={<p>{route()} not found</p>}>
<Match when={route() == "home"}>
<Nested />
</Match>
<Match when={route() == "graph"}>
<p></p>
</Match>
</Switch>
</div>
</CountProvider>
);
};
export default App;

View File

@ -0,0 +1,42 @@
import { createSignal, createContext, useContext, JSXElement } from "solid-js";
import { PYAPI } from "./message";
export const makeCountContext = () => {
const [machines, setMachines] = createSignal<string[]>([]);
const [loading, setLoading] = createSignal(false);
PYAPI.list_machines.receive((machines) => {
setLoading(false);
setMachines(machines);
});
return [
{ loading, machines },
{
getMachines: () => {
// When the gtk function sends its data the loading state will be set to false
setLoading(true);
PYAPI.list_machines.dispatch(null);
},
},
] as const;
// `as const` forces tuple type inference
};
type CountContextType = ReturnType<typeof makeCountContext>;
export const CountContext = createContext<CountContextType>([
{ loading: () => false, machines: () => [] },
{
getMachines: () => {},
},
]);
export const useCountContext = () => useContext(CountContext);
export function CountProvider(props: { children: JSXElement }) {
return (
<CountContext.Provider value={makeCountContext()}>
{props.children}
</CountContext.Provider>
);
}

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,18 @@
/* @refresh reload */
import { render } from "solid-js/web";
import "./index.css";
import App from "./App";
const root = document.getElementById("app");
// @ts-ignore: add the clan scope to the window object so we can register callbacks for gtk
window.clan = window.clan || {};
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?"
);
}
render(() => <App />, root!);

View File

@ -0,0 +1,22 @@
const deserialize = (fn: Function) => (str: string) => {
try {
fn(JSON.parse(str));
} catch (e) {
alert(`Error parsing JSON: ${e}`);
}
};
export const PYAPI = {
list_machines: {
dispatch: (data: null) =>
// @ts-ignore
window.webkit.messageHandlers.gtk.postMessage({
method: "list_machines",
data,
}),
receive: (fn: (response: string[]) => void) => {
// @ts-ignore
window.clan.list_machines = deserialize(fn);
},
},
};

View File

@ -0,0 +1,29 @@
import { For, Match, Switch, type Component } from "solid-js";
import { useCountContext } from "./Config";
export const Nested: Component = () => {
const [{ machines, loading }, { getMachines }] = useCountContext();
return (
<div>
<button onClick={() => getMachines()} class="btn btn-primary">
Get machines
</button>
<hr />
<Switch>
<Match when={loading()}>Loading...</Match>
<Match when={!loading() && machines().length === 0}>
No machines found
</Match>
<Match when={!loading() && machines().length}>
<For each={machines()}>
{(machine, i) => (
<li>
{i() + 1}: {machine}
</li>
)}
</For>
</Match>
</Switch>
</div>
);
};

View File

@ -0,0 +1,9 @@
const daisyui = require("daisyui");
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [daisyui],
};

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"strict": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"noEmit": true,
"isolatedModules": true,
},
}

View File

@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
// import devtools from "solid-devtools/vite";
export default defineConfig({
plugins: [
/*
Uncomment the following line to enable solid-devtools.
For more info see https://github.com/thetarnav/solid-devtools/tree/main/packages/extension#readme
*/
// devtools(),
solidPlugin(),
],
server: {
port: 3000,
},
build: {
target: "safari11",
// assetsDi
manifest: true,
},
});

View File

@ -0,0 +1,21 @@
{ dream2nix, config, ... }:
{
imports = [ dream2nix.modules.dream2nix.WIP-nodejs-builder-v3 ];
mkDerivation = {
src = ./app;
};
deps =
{ nixpkgs, ... }:
{
inherit (nixpkgs) stdenv;
};
WIP-nodejs-builder-v3 = {
packageLockFile = "${config.mkDerivation.src}/package-lock.json";
};
name = "@clan/webview-ui";
version = "0.0.1";
}

View File

@ -0,0 +1,34 @@
{ inputs, ... }:
{
perSystem =
{
system,
pkgs,
config,
...
}:
let
node_modules-dev = config.packages.webview-ui.prepared-dev;
in
{
packages.webview-ui = inputs.dream2nix.lib.evalModules {
packageSets.nixpkgs = inputs.dream2nix.inputs.nixpkgs.legacyPackages.${system};
modules = [ ./default.nix ];
};
devShells.webview-ui = pkgs.mkShell {
inputsFrom = [ config.packages.webview-ui.out ];
shellHook = ''
ID=${node_modules-dev}
currID=$(cat .dream2nix/.node_modules_id 2> /dev/null)
mkdir -p .dream2nix
if [[ "$ID" != "$currID" || ! -d "app/node_modules" ]];
then
${pkgs.rsync}/bin/rsync -a --chmod=ug+w --delete ${node_modules-dev}/node_modules/ ./app/node_modules/
echo -n $ID > .dream2nix/.node_modules_id
echo "Ok: node_modules updated"
fi
'';
};
};
}