palmier 0.7.6 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/shared-prompt.js +1 -1
- package/dist/commands/init.js +3 -2
- package/dist/commands/pair.js +1 -1
- package/dist/commands/run.js +4 -4
- package/dist/commands/serve.js +1 -1
- package/dist/config.js +2 -2
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/events.js +1 -1
- package/dist/mcp-tools.js +64 -1
- package/dist/nats-client.d.ts +1 -1
- package/dist/nats-client.js +6 -3
- package/dist/pwa/assets/index-Bt8Hhaw3.js +118 -0
- package/dist/pwa/assets/{web-DnuoxUd4.js → web-CkWrlNwc.js} +1 -1
- package/dist/pwa/assets/{web-7raT3zOZ.js → web-lx34oBi7.js} +1 -1
- package/dist/pwa/index.html +1 -1
- package/dist/pwa/service-worker.js +1 -1
- package/dist/types.d.ts +2 -1
- package/package.json +1 -1
- package/palmier-server/PRODUCTION.md +31 -28
- package/palmier-server/README.md +35 -5
- package/palmier-server/nats.conf +9 -5
- package/palmier-server/package.json +2 -1
- package/palmier-server/pnpm-lock.yaml +6 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +58 -0
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +9 -5
- package/palmier-server/pwa/src/pages/PairHost.tsx +6 -3
- package/palmier-server/server/package.json +3 -1
- package/palmier-server/server/src/index.ts +83 -2
- package/palmier-server/server/src/nats-jwt.ts +299 -0
- package/palmier-server/server/src/nats-setup.ts +48 -0
- package/palmier-server/server/src/nats.ts +12 -4
- package/palmier-server/server/src/routes/device.ts +24 -0
- package/palmier-server/server/src/routes/hosts.ts +13 -2
- package/palmier-server/spec.md +6 -5
- package/src/agents/shared-prompt.ts +1 -1
- package/src/commands/init.ts +7 -5
- package/src/commands/pair.ts +1 -1
- package/src/commands/run.ts +4 -4
- package/src/commands/serve.ts +1 -1
- package/src/config.ts +2 -2
- package/src/device-capabilities.ts +1 -0
- package/src/events.ts +1 -1
- package/src/mcp-tools.ts +68 -1
- package/src/nats-client.ts +10 -3
- package/src/types.ts +3 -2
- package/test/agent-instructions.test.ts +10 -10
- package/dist/pwa/assets/index-uSwkmHBs.js +0 -118
|
@@ -1 +1 @@
|
|
|
1
|
-
import{W as t}from"./index-
|
|
1
|
+
import{W as t}from"./index-Bt8Hhaw3.js";class s extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{s as AppWeb};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{W as p}from"./index-
|
|
1
|
+
import{W as p}from"./index-Bt8Hhaw3.js";class f extends p{constructor(){super(...arguments),this.group="CapacitorStorage"}async configure({group:e}){typeof e=="string"&&(this.group=e)}async get(e){return{value:this.impl.getItem(this.applyPrefix(e.key))}}async set(e){this.impl.setItem(this.applyPrefix(e.key),e.value)}async remove(e){this.impl.removeItem(this.applyPrefix(e.key))}async keys(){return{keys:this.rawKeys().map(t=>t.substring(this.prefix.length))}}async clear(){for(const e of this.rawKeys())this.impl.removeItem(e)}async migrate(){var e;const t=[],s=[],n="_cap_",o=Object.keys(this.impl).filter(i=>i.indexOf(n)===0);for(const i of o){const r=i.substring(n.length),a=(e=this.impl.getItem(i))!==null&&e!==void 0?e:"",{value:l}=await this.get({key:r});typeof l=="string"?s.push(r):(await this.set({key:r,value:a}),t.push(r))}return{migrated:t,existing:s}}async removeOld(){const e="_cap_",t=Object.keys(this.impl).filter(s=>s.indexOf(e)===0);for(const s of t)this.impl.removeItem(s)}get impl(){return window.localStorage}get prefix(){return this.group==="NativeStorage"?"":`${this.group}.`}rawKeys(){return Object.keys(this.impl).filter(e=>e.indexOf(this.prefix)===0)}applyPrefix(e){return this.prefix+e}}export{f as PreferencesWeb};
|
package/dist/pwa/index.html
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
9
9
|
<title>Palmier</title>
|
|
10
10
|
<meta name="description" content="Remote control for AI agents running on your own machine. Schedule tasks, approve permissions, and get push notifications." />
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-Bt8Hhaw3.js"></script>
|
|
12
12
|
<link rel="stylesheet" crossorigin href="/assets/index-B-ByUHPS.css">
|
|
13
13
|
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
|
14
14
|
<body>
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
try{self["workbox:core:7.3.0"]&&_()}catch{}const N=(n,...e)=>{let t=n;return e.length>0&&(t+=` :: ${JSON.stringify(e)}`),t},E=N;class h extends Error{constructor(e,t){const s=E(e,t);super(s),this.name=e,this.details=t}}const f={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:typeof registration<"u"?registration.scope:""},U=n=>[f.prefix,n,f.suffix].filter(e=>e&&e.length>0).join("-"),O=n=>{for(const e of Object.keys(f))n(e)},L={updateDetails:n=>{O(e=>{typeof n[e]=="string"&&(f[e]=n[e])})},getGoogleAnalyticsName:n=>n||U(f.googleAnalytics),getPrecacheName:n=>n||U(f.precache),getPrefix:()=>f.prefix,getRuntimeName:n=>n||U(f.runtime),getSuffix:()=>f.suffix};function v(n,e){const t=e();return n.waitUntil(t),t}try{self["workbox:precaching:7.3.0"]&&_()}catch{}const A="__WB_REVISION__";function M(n){if(!n)throw new h("add-to-cache-list-unexpected-type",{entry:n});if(typeof n=="string"){const i=new URL(n,location.href);return{cacheKey:i.href,url:i.href}}const{revision:e,url:t}=n;if(!t)throw new h("add-to-cache-list-unexpected-type",{entry:n});if(!e){const i=new URL(t,location.href);return{cacheKey:i.href,url:i.href}}const s=new URL(t,location.href),a=new URL(t,location.href);return s.searchParams.set(A,e),{cacheKey:s.href,url:a.href}}class W{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)},this.cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:s})=>{if(e.type==="install"&&t&&t.originalRequest&&t.originalRequest instanceof Request){const a=t.originalRequest.url;s?this.notUpdatedURLs.push(a):this.updatedURLs.push(a)}return s}}}class q{constructor({precacheController:e}){this.cacheKeyWillBeUsed=async({request:t,params:s})=>{const a=(s==null?void 0:s.cacheKey)||this._precacheController.getCacheKeyForURL(t.url);return a?new Request(a,{headers:t.headers}):t},this._precacheController=e}}let w;function S(){if(w===void 0){const n=new Response("");if("body"in n)try{new Response(n.body),w=!0}catch{w=!1}w=!1}return w}async function j(n,e){let t=null;if(n.url&&(t=new URL(n.url).origin),t!==self.location.origin)throw new h("cross-origin-copy-response",{origin:t});const s=n.clone(),i={headers:new Headers(s.headers),status:s.status,statusText:s.statusText},r=S()?s.body:await s.blob();return new Response(r,i)}const D=n=>new URL(String(n),location.href).href.replace(new RegExp(`^${location.origin}`),"");function T(n,e){const t=new URL(n);for(const s of e)t.searchParams.delete(s);return t.href}async function H(n,e,t,s){const a=T(e.url,t);if(e.url===a)return n.match(e,s);const i=Object.assign(Object.assign({},s),{ignoreSearch:!0}),r=await n.keys(e,i);for(const c of r){const o=T(c.url,t);if(a===o)return n.match(c,s)}}class F{constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}}const B=new Set;async function $(){for(const n of B)await n()}function V(n){return new Promise(e=>setTimeout(e,n))}try{self["workbox:strategies:7.3.0"]&&_()}catch{}function C(n){return typeof n=="string"?new Request(n):n}class G{constructor(e,t){this._cacheKeys={},Object.assign(this,t),this.event=t.event,this._strategy=e,this._handlerDeferred=new F,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map;for(const s of this._plugins)this._pluginStateMap.set(s,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){const{event:t}=this;let s=C(e);if(s.mode==="navigate"&&t instanceof FetchEvent&&t.preloadResponse){const r=await t.preloadResponse;if(r)return r}const a=this.hasCallback("fetchDidFail")?s.clone():null;try{for(const r of this.iterateCallbacks("requestWillFetch"))s=await r({request:s.clone(),event:t})}catch(r){if(r instanceof Error)throw new h("plugin-error-request-will-fetch",{thrownErrorMessage:r.message})}const i=s.clone();try{let r;r=await fetch(s,s.mode==="navigate"?void 0:this._strategy.fetchOptions);for(const c of this.iterateCallbacks("fetchDidSucceed"))r=await c({event:t,request:i,response:r});return r}catch(r){throw a&&await this.runCallbacks("fetchDidFail",{error:r,event:t,originalRequest:a.clone(),request:i.clone()}),r}}async fetchAndCachePut(e){const t=await this.fetch(e),s=t.clone();return this.waitUntil(this.cachePut(e,s)),t}async cacheMatch(e){const t=C(e);let s;const{cacheName:a,matchOptions:i}=this._strategy,r=await this.getCacheKey(t,"read"),c=Object.assign(Object.assign({},i),{cacheName:a});s=await caches.match(r,c);for(const o of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await o({cacheName:a,matchOptions:i,cachedResponse:s,request:r,event:this.event})||void 0;return s}async cachePut(e,t){const s=C(e);await V(0);const a=await this.getCacheKey(s,"write");if(!t)throw new h("cache-put-with-no-response",{url:D(a.url)});const i=await this._ensureResponseSafeToCache(t);if(!i)return!1;const{cacheName:r,matchOptions:c}=this._strategy,o=await self.caches.open(r),l=this.hasCallback("cacheDidUpdate"),d=l?await H(o,a.clone(),["__WB_REVISION__"],c):null;try{await o.put(a,l?i.clone():i)}catch(u){if(u instanceof Error)throw u.name==="QuotaExceededError"&&await $(),u}for(const u of this.iterateCallbacks("cacheDidUpdate"))await u({cacheName:r,oldResponse:d,newResponse:i.clone(),request:a,event:this.event});return!0}async getCacheKey(e,t){const s=`${e.url} | ${t}`;if(!this._cacheKeys[s]){let a=e;for(const i of this.iterateCallbacks("cacheKeyWillBeUsed"))a=C(await i({mode:t,request:a,event:this.event,params:this.params}));this._cacheKeys[s]=a}return this._cacheKeys[s]}hasCallback(e){for(const t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(const s of this.iterateCallbacks(e))await s(t)}*iterateCallbacks(e){for(const t of this._strategy.plugins)if(typeof t[e]=="function"){const s=this._pluginStateMap.get(t);yield i=>{const r=Object.assign(Object.assign({},i),{state:s});return t[e](r)}}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){for(;this._extendLifetimePromises.length;){const e=this._extendLifetimePromises.splice(0),s=(await Promise.allSettled(e)).find(a=>a.status==="rejected");if(s)throw s.reason}}destroy(){this._handlerDeferred.resolve(null)}async _ensureResponseSafeToCache(e){let t=e,s=!1;for(const a of this.iterateCallbacks("cacheWillUpdate"))if(t=await a({request:this.request,response:t,event:this.event})||void 0,s=!0,!t)break;return s||t&&t.status!==200&&(t=void 0),t}}class J{constructor(e={}){this.cacheName=L.getRuntimeName(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){const[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});const t=e.event,s=typeof e.request=="string"?new Request(e.request):e.request,a="params"in e?e.params:void 0,i=new G(this,{event:t,request:s,params:a}),r=this._getResponse(i,s,t),c=this._awaitComplete(r,i,s,t);return[r,c]}async _getResponse(e,t,s){await e.runCallbacks("handlerWillStart",{event:s,request:t});let a;try{if(a=await this._handle(t,e),!a||a.type==="error")throw new h("no-response",{url:t.url})}catch(i){if(i instanceof Error){for(const r of e.iterateCallbacks("handlerDidError"))if(a=await r({error:i,event:s,request:t}),a)break}if(!a)throw i}for(const i of e.iterateCallbacks("handlerWillRespond"))a=await i({event:s,request:t,response:a});return a}async _awaitComplete(e,t,s,a){let i,r;try{i=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:a,request:s,response:i}),await t.doneWaiting()}catch(c){c instanceof Error&&(r=c)}if(await t.runCallbacks("handlerDidComplete",{event:a,request:s,response:i,error:r}),t.destroy(),r)throw r}}class p extends J{constructor(e={}){e.cacheName=L.getPrecacheName(e.cacheName),super(e),this._fallbackToNetwork=e.fallbackToNetwork!==!1,this.plugins.push(p.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){const s=await t.cacheMatch(e);return s||(t.event&&t.event.type==="install"?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let s;const a=t.params||{};if(this._fallbackToNetwork){const i=a.integrity,r=e.integrity,c=!r||r===i;s=await t.fetch(new Request(e,{integrity:e.mode!=="no-cors"?r||i:void 0})),i&&c&&e.mode!=="no-cors"&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,s.clone()))}else throw new h("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return s}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();const s=await t.fetch(e);if(!await t.cachePut(e,s.clone()))throw new h("bad-precaching-response",{url:e.url,status:s.status});return s}_useDefaultCacheabilityPluginIfNeeded(){let e=null,t=0;for(const[s,a]of this.plugins.entries())a!==p.copyRedirectedCacheableResponsesPlugin&&(a===p.defaultPrecacheCacheabilityPlugin&&(e=s),a.cacheWillUpdate&&t++);t===0?this.plugins.push(p.defaultPrecacheCacheabilityPlugin):t>1&&e!==null&&this.plugins.splice(e,1)}}p.defaultPrecacheCacheabilityPlugin={async cacheWillUpdate({response:n}){return!n||n.status>=400?null:n}};p.copyRedirectedCacheableResponsesPlugin={async cacheWillUpdate({response:n}){return n.redirected?await j(n):n}};class Q{constructor({cacheName:e,plugins:t=[],fallbackToNetwork:s=!0}={}){this._urlsToCacheKeys=new Map,this._urlsToCacheModes=new Map,this._cacheKeysToIntegrities=new Map,this._strategy=new p({cacheName:L.getPrecacheName(e),plugins:[...t,new q({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this._strategy}precache(e){this.addToCacheList(e),this._installAndActiveListenersAdded||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this._installAndActiveListenersAdded=!0)}addToCacheList(e){const t=[];for(const s of e){typeof s=="string"?t.push(s):s&&s.revision===void 0&&t.push(s.url);const{cacheKey:a,url:i}=M(s),r=typeof s!="string"&&s.revision?"reload":"default";if(this._urlsToCacheKeys.has(i)&&this._urlsToCacheKeys.get(i)!==a)throw new h("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(i),secondEntry:a});if(typeof s!="string"&&s.integrity){if(this._cacheKeysToIntegrities.has(a)&&this._cacheKeysToIntegrities.get(a)!==s.integrity)throw new h("add-to-cache-list-conflicting-integrities",{url:i});this._cacheKeysToIntegrities.set(a,s.integrity)}if(this._urlsToCacheKeys.set(i,a),this._urlsToCacheModes.set(i,r),t.length>0){const c=`Workbox is precaching URLs without revision info: ${t.join(", ")}
|
|
2
|
-
This is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(c)}}}install(e){return v(e,async()=>{const t=new W;this.strategy.plugins.push(t);for(const[i,r]of this._urlsToCacheKeys){const c=this._cacheKeysToIntegrities.get(r),o=this._urlsToCacheModes.get(i),l=new Request(i,{integrity:c,cache:o,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:r},request:l,event:e}))}const{updatedURLs:s,notUpdatedURLs:a}=t;return{updatedURLs:s,notUpdatedURLs:a}})}activate(e){return v(e,async()=>{const t=await self.caches.open(this.strategy.cacheName),s=await t.keys(),a=new Set(this._urlsToCacheKeys.values()),i=[];for(const r of s)a.has(r.url)||(await t.delete(r),i.push(r.url));return{deletedURLs:i}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){const t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){const t=e instanceof Request?e.url:e,s=this.getCacheKeyForURL(t);if(s)return(await self.caches.open(this.strategy.cacheName)).match(s)}createHandlerBoundToURL(e){const t=this.getCacheKeyForURL(e);if(!t)throw new h("non-precached-url",{url:e});return s=>(s.request=new Request(e),s.params=Object.assign({cacheKey:t},s.params),this.strategy.handle(s))}}let k;const x=()=>(k||(k=new Q),k);try{self["workbox:routing:7.3.0"]&&_()}catch{}const I="GET",b=n=>n&&typeof n=="object"?n:{handle:n};class R{constructor(e,t,s=I){this.handler=b(t),this.match=e,this.method=s}setCatchHandler(e){this.catchHandler=b(e)}}class z extends R{constructor(e,t,s){const a=({url:i})=>{const r=e.exec(i.href);if(r&&!(i.origin!==location.origin&&r.index!==0))return r.slice(1)};super(a,t,s)}}class X{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener("fetch",(e=>{const{request:t}=e,s=this.handleRequest({request:t,event:e});s&&e.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(e=>{if(e.data&&e.data.type==="CACHE_URLS"){const{payload:t}=e.data,s=Promise.all(t.urlsToCache.map(a=>{typeof a=="string"&&(a=[a]);const i=new Request(...a);return this.handleRequest({request:i,event:e})}));e.waitUntil(s),e.ports&&e.ports[0]&&s.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){const s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;const a=s.origin===location.origin,{params:i,route:r}=this.findMatchingRoute({event:t,request:e,sameOrigin:a,url:s});let c=r&&r.handler;const o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;let l;try{l=c.handle({url:s,request:e,event:t,params:i})}catch(u){l=Promise.reject(u)}const d=r&&r.catchHandler;return l instanceof Promise&&(this._catchHandler||d)&&(l=l.catch(async u=>{if(d)try{return await d.handle({url:s,request:e,event:t,params:i})}catch(g){g instanceof Error&&(u=g)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw u})),l}findMatchingRoute({url:e,sameOrigin:t,request:s,event:a}){const i=this._routes.get(s.method)||[];for(const r of i){let c;const o=r.match({url:e,sameOrigin:t,request:s,event:a});if(o)return c=o,(Array.isArray(c)&&c.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o=="boolean")&&(c=void 0),{route:r,params:c}}return{}}setDefaultHandler(e,t=I){this._defaultHandlerMap.set(t,b(e))}setCatchHandler(e){this._catchHandler=b(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new h("unregister-route-but-not-found-with-method",{method:e.method});const t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new h("unregister-route-route-not-registered")}}let m;const Y=()=>(m||(m=new X,m.addFetchListener(),m.addCacheListener()),m);function Z(n,e,t){let s;if(typeof n=="string"){const i=new URL(n,location.href),r=({url:c})=>c.href===i.href;s=new R(r,e,t)}else if(n instanceof RegExp)s=new z(n,e,t);else if(typeof n=="function")s=new R(n,e,t);else if(n instanceof R)s=n;else throw new h("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});return Y().registerRoute(s),s}function ee(n,e=[]){for(const t of[...n.searchParams.keys()])e.some(s=>s.test(t))&&n.searchParams.delete(t);return n}function*te(n,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:t="index.html",cleanURLs:s=!0,urlManipulation:a}={}){const i=new URL(n,location.href);i.hash="",yield i.href;const r=ee(i,e);if(yield r.href,t&&r.pathname.endsWith("/")){const c=new URL(r.href);c.pathname+=t,yield c.href}if(s){const c=new URL(r.href);c.pathname+=".html",yield c.href}if(a){const c=a({url:i});for(const o of c)yield o.href}}class se extends R{constructor(e,t){const s=({request:a})=>{const i=e.getURLsToCacheKeys();for(const r of te(a.url,t)){const c=i.get(r);if(c){const o=e.getIntegrityForCacheKey(c);return{cacheKey:c,integrity:o}}}};super(s,e.strategy)}}function ne(n){const e=x(),t=new se(e,n);Z(t)}function ae(n){x().precache(n)}function ie(n,e){ae(n),ne(e)}ie([{"revision":"38013143dc2183340ede8bc1c5124507","url":"registerSW.js"},{"revision":"
|
|
2
|
+
This is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(c)}}}install(e){return v(e,async()=>{const t=new W;this.strategy.plugins.push(t);for(const[i,r]of this._urlsToCacheKeys){const c=this._cacheKeysToIntegrities.get(r),o=this._urlsToCacheModes.get(i),l=new Request(i,{integrity:c,cache:o,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:r},request:l,event:e}))}const{updatedURLs:s,notUpdatedURLs:a}=t;return{updatedURLs:s,notUpdatedURLs:a}})}activate(e){return v(e,async()=>{const t=await self.caches.open(this.strategy.cacheName),s=await t.keys(),a=new Set(this._urlsToCacheKeys.values()),i=[];for(const r of s)a.has(r.url)||(await t.delete(r),i.push(r.url));return{deletedURLs:i}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){const t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){const t=e instanceof Request?e.url:e,s=this.getCacheKeyForURL(t);if(s)return(await self.caches.open(this.strategy.cacheName)).match(s)}createHandlerBoundToURL(e){const t=this.getCacheKeyForURL(e);if(!t)throw new h("non-precached-url",{url:e});return s=>(s.request=new Request(e),s.params=Object.assign({cacheKey:t},s.params),this.strategy.handle(s))}}let k;const x=()=>(k||(k=new Q),k);try{self["workbox:routing:7.3.0"]&&_()}catch{}const I="GET",b=n=>n&&typeof n=="object"?n:{handle:n};class R{constructor(e,t,s=I){this.handler=b(t),this.match=e,this.method=s}setCatchHandler(e){this.catchHandler=b(e)}}class z extends R{constructor(e,t,s){const a=({url:i})=>{const r=e.exec(i.href);if(r&&!(i.origin!==location.origin&&r.index!==0))return r.slice(1)};super(a,t,s)}}class X{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener("fetch",(e=>{const{request:t}=e,s=this.handleRequest({request:t,event:e});s&&e.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(e=>{if(e.data&&e.data.type==="CACHE_URLS"){const{payload:t}=e.data,s=Promise.all(t.urlsToCache.map(a=>{typeof a=="string"&&(a=[a]);const i=new Request(...a);return this.handleRequest({request:i,event:e})}));e.waitUntil(s),e.ports&&e.ports[0]&&s.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){const s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;const a=s.origin===location.origin,{params:i,route:r}=this.findMatchingRoute({event:t,request:e,sameOrigin:a,url:s});let c=r&&r.handler;const o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;let l;try{l=c.handle({url:s,request:e,event:t,params:i})}catch(u){l=Promise.reject(u)}const d=r&&r.catchHandler;return l instanceof Promise&&(this._catchHandler||d)&&(l=l.catch(async u=>{if(d)try{return await d.handle({url:s,request:e,event:t,params:i})}catch(g){g instanceof Error&&(u=g)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw u})),l}findMatchingRoute({url:e,sameOrigin:t,request:s,event:a}){const i=this._routes.get(s.method)||[];for(const r of i){let c;const o=r.match({url:e,sameOrigin:t,request:s,event:a});if(o)return c=o,(Array.isArray(c)&&c.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o=="boolean")&&(c=void 0),{route:r,params:c}}return{}}setDefaultHandler(e,t=I){this._defaultHandlerMap.set(t,b(e))}setCatchHandler(e){this._catchHandler=b(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new h("unregister-route-but-not-found-with-method",{method:e.method});const t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new h("unregister-route-route-not-registered")}}let m;const Y=()=>(m||(m=new X,m.addFetchListener(),m.addCacheListener()),m);function Z(n,e,t){let s;if(typeof n=="string"){const i=new URL(n,location.href),r=({url:c})=>c.href===i.href;s=new R(r,e,t)}else if(n instanceof RegExp)s=new z(n,e,t);else if(typeof n=="function")s=new R(n,e,t);else if(n instanceof R)s=n;else throw new h("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});return Y().registerRoute(s),s}function ee(n,e=[]){for(const t of[...n.searchParams.keys()])e.some(s=>s.test(t))&&n.searchParams.delete(t);return n}function*te(n,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:t="index.html",cleanURLs:s=!0,urlManipulation:a}={}){const i=new URL(n,location.href);i.hash="",yield i.href;const r=ee(i,e);if(yield r.href,t&&r.pathname.endsWith("/")){const c=new URL(r.href);c.pathname+=t,yield c.href}if(s){const c=new URL(r.href);c.pathname+=".html",yield c.href}if(a){const c=a({url:i});for(const o of c)yield o.href}}class se extends R{constructor(e,t){const s=({request:a})=>{const i=e.getURLsToCacheKeys();for(const r of te(a.url,t)){const c=i.get(r);if(c){const o=e.getIntegrityForCacheKey(c);return{cacheKey:c,integrity:o}}}};super(s,e.strategy)}}function ne(n){const e=x(),t=new se(e,n);Z(t)}function ae(n){x().precache(n)}function ie(n,e){ae(n),ne(e)}ie([{"revision":"38013143dc2183340ede8bc1c5124507","url":"registerSW.js"},{"revision":"23ecab08d0815501f6e332dae8cf9986","url":"index.html"},{"revision":null,"url":"assets/web-lx34oBi7.js"},{"revision":null,"url":"assets/web-CkWrlNwc.js"},{"revision":null,"url":"assets/index-Bt8Hhaw3.js"},{"revision":null,"url":"assets/index-B-ByUHPS.css"},{"revision":"fcc457fce855ad0df7178e0786c0d4ef","url":"apple-touch-icon.png"},{"revision":"276650c30bc4effc7d649ec66519aab6","url":"favicon.ico"},{"revision":"2e46512b835c05e17787059909305f22","url":"pwa-192x192.png"},{"revision":"ec5652b5834b4711337743e80e506a41","url":"pwa-512x512.png"},{"revision":"9f51698004b9cc4d787c75695b74de9d","url":"manifest.webmanifest"}]);const re="/api/push/respond";self.addEventListener("message",n=>{});self.addEventListener("push",n=>{var r;if(!n.data)return;let e;try{e=n.data.json()}catch{e={title:"Palmier",body:n.data.text()}}const t=e.type??((r=e.data)==null?void 0:r.type);if(t==="confirm-dismiss"||t==="permission-dismiss"||t==="input-dismiss"){const c=e.data??e,o=c.host_id,l=c.session_id,d=c.task_id;n.waitUntil(self.registration.getNotifications().then(u=>{var g,P,K;for(const y of u)if(((g=y.data)==null?void 0:g.host_id)===o){if(l&&((P=y.data)==null?void 0:P.session_id)===l){y.close();continue}d&&((K=y.data)==null?void 0:K.task_id)===d&&y.close()}}));return}const s=e.title??"Palmier";let a=e.body??"";!a&&t==="confirm"&&(a="A task requires confirmation to run."),!a&&t==="permission"&&(a="A task needs additional permissions to continue."),!a&&t==="input"&&(a="A task needs your input to continue.");const i={body:a,icon:"/pwa-192x192.png",badge:"/pwa-192x192.png",data:e.data??e,vibrate:[100,50,100]};t==="confirm"&&(i.actions=[{action:"confirm",title:"Confirm"},{action:"abort",title:"Abort"}]),n.waitUntil(self.registration.showNotification(s,i))});self.addEventListener("notificationclick",n=>{const e=n.notification;e.close();const t=e.data??{},s=n.action;if(s&&t.type==="confirm"&&t.session_id&&t.host_id){const a=s==="confirm"?"confirmed":"aborted";n.waitUntil(fetch(re,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({session_id:t.session_id,host_id:t.host_id,response:a})}).catch(i=>{console.error("Failed to send push response:",i)}))}else{const a=t.task_id,i=t.run_id,r=a&&i?`/runs/${encodeURIComponent(a)}/${encodeURIComponent(i)}`:a?`/runs/${encodeURIComponent(a)}/latest`:"/";n.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(c=>{for(const o of c)if(o.url.includes(self.location.origin)&&"focus"in o)return o.navigate(r),o.focus();return self.clients.openWindow(r)}))}});self.addEventListener("install",()=>{self.skipWaiting()});self.addEventListener("activate",n=>{n.waitUntil(self.clients.claim())});
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -99,7 +99,7 @@ No need to configure TLS in NATS itself.
|
|
|
99
99
|
sudo systemctl enable --now caddy
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
## 2.
|
|
102
|
+
## 2. NATS + PostgreSQL
|
|
103
103
|
|
|
104
104
|
Create a directory for production Docker config:
|
|
105
105
|
|
|
@@ -107,7 +107,30 @@ Create a directory for production Docker config:
|
|
|
107
107
|
mkdir -p ~/palmier-prod
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
###
|
|
110
|
+
### 2a. Generate NATS auth keys and config
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
cd server && pnpm nats-setup
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Follow the on-screen instructions — it outputs the `NATS_ACCOUNT_SEED` env var (for step 6) and the NATS config snippet (for `nats.conf` below). Store the operator seed securely as a backup.
|
|
117
|
+
|
|
118
|
+
### 2b. nats.conf
|
|
119
|
+
|
|
120
|
+
Create `~/palmier-prod/nats.conf`. Paste the auth snippet from step 2a after the websocket block:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
listen: 0.0.0.0:4222
|
|
124
|
+
|
|
125
|
+
websocket {
|
|
126
|
+
listen: "0.0.0.0:9222"
|
|
127
|
+
no_tls: true
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Paste the operator/resolver/resolver_preload output from nats-setup here
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 2c. docker-compose.yml
|
|
111
134
|
|
|
112
135
|
Create `~/palmier-prod/docker-compose.yml`:
|
|
113
136
|
|
|
@@ -141,34 +164,13 @@ volumes:
|
|
|
141
164
|
pgdata:
|
|
142
165
|
```
|
|
143
166
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
Create `~/palmier-prod/nats.conf`:
|
|
147
|
-
|
|
148
|
-
```
|
|
149
|
-
listen: 0.0.0.0:4222
|
|
150
|
-
|
|
151
|
-
websocket {
|
|
152
|
-
listen: "0.0.0.0:9222"
|
|
153
|
-
no_tls: true
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
authorization {
|
|
157
|
-
token: "<generate-a-new-token>"
|
|
158
|
-
}
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
Generate secrets:
|
|
167
|
+
Generate a Postgres password:
|
|
162
168
|
|
|
163
169
|
```bash
|
|
164
|
-
# NATS token
|
|
165
|
-
openssl rand -hex 32
|
|
166
|
-
|
|
167
|
-
# Postgres password
|
|
168
170
|
openssl rand -hex 16
|
|
169
171
|
```
|
|
170
172
|
|
|
171
|
-
Start
|
|
173
|
+
### 2d. Start containers
|
|
172
174
|
|
|
173
175
|
```bash
|
|
174
176
|
cd ~/palmier-prod
|
|
@@ -177,9 +179,9 @@ docker compose up -d
|
|
|
177
179
|
|
|
178
180
|
## 3. NATS TCP for Remote Hosts
|
|
179
181
|
|
|
180
|
-
Hosts connect to NATS over TCP (port 4222). Since Caddy only handles HTTP/WebSocket, port 4222 must be open on the firewall for hosts to connect.
|
|
182
|
+
Hosts connect to NATS over TCP (port 4222). Since Caddy only handles HTTP/WebSocket, port 4222 must be open on the firewall for hosts to connect. Each host authenticates with its own JWT (issued during `palmier init`), scoped to only its own NATS subjects.
|
|
181
183
|
|
|
182
|
-
For encrypted host connections, you can add a TLS-terminating TCP proxy (e.g., nginx stream block) in front of port 4222.
|
|
184
|
+
For encrypted host connections, you can add a TLS-terminating TCP proxy (e.g., nginx stream block) in front of port 4222.
|
|
183
185
|
|
|
184
186
|
## 4. Firewall
|
|
185
187
|
|
|
@@ -215,7 +217,7 @@ DATABASE_URL=postgresql://palmier:<your-pg-password>@localhost:5432/palmier
|
|
|
215
217
|
NATS_URL=nats://localhost:4222
|
|
216
218
|
NATS_HOST_URL=nats://nats.palmier.me:4222
|
|
217
219
|
NATS_WS_URL=wss://nats.palmier.me
|
|
218
|
-
|
|
220
|
+
NATS_ACCOUNT_SEED=<from-nats-setup>
|
|
219
221
|
|
|
220
222
|
VAPID_PUBLIC_KEY=<generated>
|
|
221
223
|
VAPID_PRIVATE_KEY=<generated>
|
|
@@ -226,6 +228,7 @@ Key differences from development:
|
|
|
226
228
|
|
|
227
229
|
- `NATS_WS_URL` uses `wss://` through Caddy (not direct `ws://`)
|
|
228
230
|
- `NATS_HOST_URL` uses your public domain so remote hosts can reach NATS
|
|
231
|
+
- `NATS_ACCOUNT_SEED` from step 2a (`pnpm nats-setup`)
|
|
229
232
|
|
|
230
233
|
Generate VAPID keys:
|
|
231
234
|
|
package/palmier-server/README.md
CHANGED
|
@@ -83,7 +83,7 @@ Palmier is a platform for remotely scheduling, managing, and executing autonomou
|
|
|
83
83
|
nats-server -c nats.conf
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
The config enables WebSocket on port 9222
|
|
86
|
+
The config enables WebSocket on port 9222. See **NATS JWT Auth Setup** below.
|
|
87
87
|
|
|
88
88
|
## Development
|
|
89
89
|
|
|
@@ -142,7 +142,7 @@ The host runs on a separate Linux machine. See the [palmier README](https://gith
|
|
|
142
142
|
| `NATS_URL` | NATS server URL (TCP, for server's own connection) | `nats://localhost:4222` |
|
|
143
143
|
| `NATS_HOST_URL` | NATS URL sent to hosts during registration (use LAN IP) | `nats://192.168.1.100:4222` |
|
|
144
144
|
| `NATS_WS_URL` | NATS WebSocket URL sent to PWA clients | `wss://nats.palmier.me` (prod) or `ws://192.168.1.100:9222` (LAN) |
|
|
145
|
-
| `
|
|
145
|
+
| `NATS_ACCOUNT_SEED` | NATS account NKey seed for signing JWTs | *(from nats-setup)* |
|
|
146
146
|
| `VAPID_PUBLIC_KEY` | VAPID public key for web push | *(generated via web-push)* |
|
|
147
147
|
| `VAPID_PRIVATE_KEY` | VAPID private key for web push | *(generated via web-push)* |
|
|
148
148
|
| `VAPID_MAILTO` | Contact email for VAPID | `mailto:admin@example.com` |
|
|
@@ -156,8 +156,9 @@ All endpoints are prefixed with `/api`. No user authentication is required.
|
|
|
156
156
|
|
|
157
157
|
| Method | Path | Description |
|
|
158
158
|
|---|---|---|
|
|
159
|
-
| `POST` | `/api/hosts/register` | Register a new host (returns hostId + NATS
|
|
160
|
-
| `GET` | `/api/config` | Get NATS
|
|
159
|
+
| `POST` | `/api/hosts/register` | Register a new host (returns hostId + NATS JWT credentials) |
|
|
160
|
+
| `GET` | `/api/config` | Get pairing-only NATS credentials (can only publish to `pair.*`) |
|
|
161
|
+
| `GET` | `/api/nats-credentials/:hostId` | Get host-scoped NATS credentials for PWA (RPC + events for one host) |
|
|
161
162
|
| `POST` | `/api/push/subscribe` | Register a push notification subscription |
|
|
162
163
|
| `DELETE` | `/api/push/subscribe` | Remove a push notification subscription |
|
|
163
164
|
| `GET` | `/api/push/vapid-key` | Get the VAPID public key |
|
|
@@ -192,7 +193,36 @@ All endpoints are prefixed with `/api`. No user authentication is required.
|
|
|
192
193
|
- **Markdown rendering** — Task results are rendered as rich formatted text using `react-markdown` with `remark-gfm` (GitHub Flavored Markdown), supporting tables, strikethrough, task lists, and autolinks.
|
|
193
194
|
- **Task confirmation** — the Dashboard discovers pending confirmations from the `task.list` RPC response (tasks with a pending request in the serve daemon's in-memory registry, reported via `task.status`). When found, it shows a full-screen confirmation modal. Push notification action buttons trigger `POST /api/push/respond`, which forwards to the `task.user_input` NATS RPC.
|
|
194
195
|
- **Task event tracking** — task lifecycle events are persisted to `status.json` on the host (for crash detection) and broadcast via `host-event.<host_id>.<task_id>` pub/sub and HTTP SSE. The PWA loads initial status from `task.list` and subscribes to events for real-time updates.
|
|
195
|
-
- **NATS
|
|
196
|
+
- **NATS auth** uses decentralized JWT/NKey authentication. Each host receives scoped credentials (can only access its own subjects). PWA clients get two-phase credentials: pairing-only JWT for the pairing flow, then host-scoped JWT after pairing. The server signs all user JWTs with the account key. Run `pnpm --filter palmier-server nats-setup` to generate keys and NATS config. See **NATS JWT Auth Setup** below.
|
|
197
|
+
|
|
198
|
+
## NATS JWT Auth Setup
|
|
199
|
+
|
|
200
|
+
NATS uses decentralized JWT/NKey authentication. Each host gets scoped credentials that restrict it to its own subjects — one host cannot read or impersonate another.
|
|
201
|
+
|
|
202
|
+
**One-time setup:**
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
# Generate operator/account NKey pairs and NATS config
|
|
206
|
+
cd server && pnpm nats-setup
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
This outputs:
|
|
210
|
+
1. An `NATS_ACCOUNT_SEED` value — add it to `server/.env`
|
|
211
|
+
2. A NATS config snippet — replace the `authorization` block in `nats.conf`
|
|
212
|
+
|
|
213
|
+
**How it works:**
|
|
214
|
+
|
|
215
|
+
| Role | Publish | Subscribe |
|
|
216
|
+
|------|---------|-----------|
|
|
217
|
+
| **Host** (id=X) | `host-event.X.>`, `host.X.>` | `host.X.>`, `pair.*` |
|
|
218
|
+
| **PWA** (pairing) | `pair.*` | *(none)* |
|
|
219
|
+
| **PWA** (connected to X) | `host.X.rpc.>` | `host-event.X.>` |
|
|
220
|
+
| **Server** | `>` | `>` |
|
|
221
|
+
|
|
222
|
+
- The **operator** key signs the **account** JWT (embedded in NATS server config, one-time)
|
|
223
|
+
- The **account** key signs **user** JWTs at runtime (per host registration, per PWA session)
|
|
224
|
+
- Host credentials are generated during `POST /api/hosts/register` and stored in `~/.config/palmier/host.json`
|
|
225
|
+
- PWA uses two-phase credentials: `GET /api/config` returns pairing-only JWT, then `GET /api/nats-credentials/:hostId` returns host-scoped JWT after pairing. A PWA client can only access the specific host it paired with.
|
|
196
226
|
|
|
197
227
|
## Related Repositories
|
|
198
228
|
|
package/palmier-server/nats.conf
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# NATS Server Configuration for Palmier
|
|
2
2
|
|
|
3
|
-
# TCP listener (for server +
|
|
3
|
+
# TCP listener (for server + host)
|
|
4
4
|
listen: 0.0.0.0:4222
|
|
5
5
|
|
|
6
6
|
# WebSocket listener (for PWA browser clients)
|
|
@@ -9,7 +9,11 @@ websocket {
|
|
|
9
9
|
no_tls: true
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
# JWT/NKey authentication
|
|
13
|
+
# Generate these values by running: cd server && pnpm nats-setup
|
|
14
|
+
# Paste the auth snippet from the output below.
|
|
15
|
+
# operator: <OPERATOR_JWT>
|
|
16
|
+
# resolver: MEMORY
|
|
17
|
+
# resolver_preload: {
|
|
18
|
+
# <ACCOUNT_PUBLIC_KEY>: <ACCOUNT_JWT>
|
|
19
|
+
# }
|
|
@@ -11,6 +11,9 @@ importers:
|
|
|
11
11
|
firebase-admin:
|
|
12
12
|
specifier: ^13.8.0
|
|
13
13
|
version: 13.8.0
|
|
14
|
+
nkeys.js:
|
|
15
|
+
specifier: ^1.1.0
|
|
16
|
+
version: 1.1.0
|
|
14
17
|
|
|
15
18
|
pwa:
|
|
16
19
|
dependencies:
|
|
@@ -96,6 +99,9 @@ importers:
|
|
|
96
99
|
nats:
|
|
97
100
|
specifier: ^2.29.1
|
|
98
101
|
version: 2.29.3
|
|
102
|
+
nkeys.js:
|
|
103
|
+
specifier: ^1.1.0
|
|
104
|
+
version: 1.1.0
|
|
99
105
|
pg:
|
|
100
106
|
specifier: ^8.13.1
|
|
101
107
|
version: 8.20.0
|
|
@@ -64,6 +64,15 @@ interface DndAccessPlugin {
|
|
|
64
64
|
check(): Promise<DndAccessResult>;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
interface FullScreenIntentResult {
|
|
68
|
+
granted: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface FullScreenIntentPlugin {
|
|
72
|
+
request(): Promise<FullScreenIntentResult>;
|
|
73
|
+
check(): Promise<FullScreenIntentResult>;
|
|
74
|
+
}
|
|
75
|
+
|
|
67
76
|
const NotificationListener = Capacitor.isNativePlatform()
|
|
68
77
|
? registerPlugin<NotificationListenerPlugin>("NotificationListener")
|
|
69
78
|
: null;
|
|
@@ -83,6 +92,10 @@ const CalendarPermission = Capacitor.isNativePlatform()
|
|
|
83
92
|
const DndAccess = Capacitor.isNativePlatform()
|
|
84
93
|
? registerPlugin<DndAccessPlugin>("DndAccess")
|
|
85
94
|
: null;
|
|
95
|
+
|
|
96
|
+
const FullScreenIntent = Capacitor.isNativePlatform()
|
|
97
|
+
? registerPlugin<FullScreenIntentPlugin>("FullScreenIntent")
|
|
98
|
+
: null;
|
|
86
99
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
87
100
|
import { useMediaQuery } from "../hooks/useMediaQuery";
|
|
88
101
|
|
|
@@ -116,6 +129,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
116
129
|
const [togglingDnd, setTogglingDnd] = useState(false);
|
|
117
130
|
const [togglingAlarm, setTogglingAlarm] = useState(false);
|
|
118
131
|
const [togglingBattery, setTogglingBattery] = useState(false);
|
|
132
|
+
const [togglingEmail, setTogglingEmail] = useState(false);
|
|
119
133
|
|
|
120
134
|
// Capability enabled = this device's client token matches the registered device for that capability
|
|
121
135
|
function isCapEnabled(cap: string): boolean {
|
|
@@ -129,6 +143,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
129
143
|
const dndEnabled = isCapEnabled("dnd");
|
|
130
144
|
const alarmEnabled = isCapEnabled("alert");
|
|
131
145
|
const batteryEnabled = isCapEnabled("battery");
|
|
146
|
+
const emailEnabled = isCapEnabled("email");
|
|
132
147
|
|
|
133
148
|
/** Update local capability tokens state after a toggle change */
|
|
134
149
|
function setCapEnabled(cap: string, enabled: boolean) {
|
|
@@ -296,6 +311,15 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
296
311
|
}
|
|
297
312
|
}
|
|
298
313
|
|
|
314
|
+
/** Ensure full-screen intent permission is granted (needed for alert + email). */
|
|
315
|
+
async function ensureFullScreenIntent(): Promise<boolean> {
|
|
316
|
+
if (!FullScreenIntent) return true;
|
|
317
|
+
const { granted } = await FullScreenIntent.check();
|
|
318
|
+
if (granted) return true;
|
|
319
|
+
const result = await FullScreenIntent.request();
|
|
320
|
+
return result.granted;
|
|
321
|
+
}
|
|
322
|
+
|
|
299
323
|
async function handleAlarmToggle() {
|
|
300
324
|
if (!request) return;
|
|
301
325
|
setTogglingAlarm(true);
|
|
@@ -304,6 +328,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
304
328
|
await request("device.capability.disable", { capability: "alert" });
|
|
305
329
|
setCapEnabled("alert", false);
|
|
306
330
|
} else {
|
|
331
|
+
if (!await ensureFullScreenIntent()) return;
|
|
307
332
|
const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
|
|
308
333
|
if (!fcmToken) { console.warn("No FCM token available"); return; }
|
|
309
334
|
await request("device.capability.enable", { capability: "alert", fcmToken });
|
|
@@ -316,6 +341,27 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
316
341
|
}
|
|
317
342
|
}
|
|
318
343
|
|
|
344
|
+
async function handleEmailToggle() {
|
|
345
|
+
if (!request) return;
|
|
346
|
+
setTogglingEmail(true);
|
|
347
|
+
try {
|
|
348
|
+
if (emailEnabled) {
|
|
349
|
+
await request("device.capability.disable", { capability: "email" });
|
|
350
|
+
setCapEnabled("email", false);
|
|
351
|
+
} else {
|
|
352
|
+
if (!await ensureFullScreenIntent()) return;
|
|
353
|
+
const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
|
|
354
|
+
if (!fcmToken) { console.warn("No FCM token available"); return; }
|
|
355
|
+
await request("device.capability.enable", { capability: "email", fcmToken });
|
|
356
|
+
setCapEnabled("email", true);
|
|
357
|
+
}
|
|
358
|
+
} catch (err) {
|
|
359
|
+
console.error("Failed to toggle email access:", err);
|
|
360
|
+
} finally {
|
|
361
|
+
setTogglingEmail(false);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
319
365
|
async function handleBatteryToggle() {
|
|
320
366
|
if (!request) return;
|
|
321
367
|
setTogglingBattery(true);
|
|
@@ -641,6 +687,18 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
641
687
|
<span className="toggle-switch-thumb" />
|
|
642
688
|
</button>
|
|
643
689
|
</label>
|
|
690
|
+
<label className="drawer-toggle">
|
|
691
|
+
<span className="drawer-toggle-label">Email Access</span>
|
|
692
|
+
<button
|
|
693
|
+
className={`toggle-switch ${emailEnabled ? "toggle-switch-on" : ""}`}
|
|
694
|
+
onClick={handleEmailToggle}
|
|
695
|
+
disabled={togglingEmail}
|
|
696
|
+
role="switch"
|
|
697
|
+
aria-checked={emailEnabled}
|
|
698
|
+
>
|
|
699
|
+
<span className="toggle-switch-thumb" />
|
|
700
|
+
</button>
|
|
701
|
+
</label>
|
|
644
702
|
</div>
|
|
645
703
|
</>
|
|
646
704
|
)}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Bump when a breaking host change is made. */
|
|
2
|
-
export const MIN_HOST_VERSION = "0.7.
|
|
2
|
+
export const MIN_HOST_VERSION = "0.7.6";
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useCallback,
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from "react";
|
|
10
|
-
import { connect, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
|
|
10
|
+
import { connect, jwtAuthenticator, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
|
|
11
11
|
import { SERVER_URL } from "../api";
|
|
12
12
|
import { useHostStore } from "./HostStoreContext";
|
|
13
13
|
import type { PairedHost } from "../types";
|
|
@@ -90,12 +90,13 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
90
90
|
|
|
91
91
|
async function init() {
|
|
92
92
|
try {
|
|
93
|
-
|
|
93
|
+
// Fetch host-scoped NATS credentials (can only access this host's subjects)
|
|
94
|
+
const res = await fetch(`${SERVER_URL}/api/nats-credentials/${activeHost!.hostId}`);
|
|
94
95
|
if (!res.ok) {
|
|
95
|
-
console.error("[NATS] Failed to fetch
|
|
96
|
+
console.error("[NATS] Failed to fetch credentials");
|
|
96
97
|
return;
|
|
97
98
|
}
|
|
98
|
-
const config = await res.json() as { natsWsUrl: string;
|
|
99
|
+
const config = await res.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
|
|
99
100
|
if (!config.natsWsUrl) {
|
|
100
101
|
console.warn("[NATS] No WebSocket URL configured");
|
|
101
102
|
return;
|
|
@@ -105,7 +106,10 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
105
106
|
console.log("[NATS] Connecting to", config.natsWsUrl);
|
|
106
107
|
const conn = await connect({
|
|
107
108
|
servers: config.natsWsUrl,
|
|
108
|
-
|
|
109
|
+
authenticator: jwtAuthenticator(
|
|
110
|
+
config.natsJwt,
|
|
111
|
+
new TextEncoder().encode(config.natsNkeySeed),
|
|
112
|
+
),
|
|
109
113
|
});
|
|
110
114
|
if (cancelled) { conn.close().catch(() => {}); return; }
|
|
111
115
|
console.log("[NATS] Connected");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { useNavigate } from "react-router-dom";
|
|
3
|
-
import { connect, StringCodec } from "nats.ws";
|
|
3
|
+
import { connect, jwtAuthenticator, StringCodec } from "nats.ws";
|
|
4
4
|
import { Capacitor } from "@capacitor/core";
|
|
5
5
|
import { Preferences } from "@capacitor/preferences";
|
|
6
6
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
@@ -54,12 +54,15 @@ export default function PairHost() {
|
|
|
54
54
|
// Server mode — pair via NATS
|
|
55
55
|
const configRes = await fetch(`${SERVER_URL}/api/config`);
|
|
56
56
|
if (!configRes.ok) throw new Error("Failed to fetch server config");
|
|
57
|
-
const config = await configRes.json() as { natsWsUrl: string;
|
|
57
|
+
const config = await configRes.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
|
|
58
58
|
if (!config.natsWsUrl) throw new Error("Server has no NATS WebSocket URL configured");
|
|
59
59
|
|
|
60
60
|
const nc = await connect({
|
|
61
61
|
servers: config.natsWsUrl,
|
|
62
|
-
|
|
62
|
+
authenticator: jwtAuthenticator(
|
|
63
|
+
config.natsJwt,
|
|
64
|
+
new TextEncoder().encode(config.natsNkeySeed),
|
|
65
|
+
),
|
|
63
66
|
});
|
|
64
67
|
|
|
65
68
|
const sc = StringCodec();
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "tsx watch src/index.ts",
|
|
7
7
|
"build": "tsc",
|
|
8
|
-
"start": "node dist/index.js"
|
|
8
|
+
"start": "node dist/index.js",
|
|
9
|
+
"nats-setup": "tsx src/nats-setup.ts"
|
|
9
10
|
},
|
|
10
11
|
"dependencies": {
|
|
11
12
|
"bcrypt": "^5.1.1",
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
"helmet": "^8.0.0",
|
|
17
18
|
"jsonwebtoken": "^9.0.2",
|
|
18
19
|
"nats": "^2.29.1",
|
|
20
|
+
"nkeys.js": "^1.1.0",
|
|
19
21
|
"pg": "^8.13.1",
|
|
20
22
|
"uuid": "^11.0.5",
|
|
21
23
|
"web-push": "^3.6.7"
|