Module AppRuntime
	EnableExplicit
	
	;- Instance structure
	Structure Instance
		InstanceID.i   ; stable unique ID — used as the JS↔SB bridge key
		Window.i
		View.i
		AppID.s
		Perms.s
	EndStructure
	
	Global NewList Instances.Instance()
	Global _NextID = 1   ; auto-incrementing ID counter
	
	;- Pending async operations
	; One pending op of each type at a time (JS is single-threaded).
	
	Global _PL_ID  = -1 : Global _PL_KMID.s  = ""   ; list
	Global _PS_ID  = -1 : Global _PS_KMID.s  = ""	; stat (standalone)
	Global _PSR_ID = -1 : Global _PSR_KMID.s = ""	; stat-before-read/delete
	Global _PR_ID  = -1 : Global _PR_KMID.s  = ""	; read
	Global _PW_ID  = -1 : Global _PW_KMID.s  = ""	; write / mkdir / delete
	Global _PC_ID  = -1 : Global _PC_KMID.s  = ""	; confirm dialog
	
	;- Private declarations
	Declare _FindByID(ID)
	Declare _FindByWindow(Win)
	Declare _HasPerm(ID, Token.s)
	Declare _SendResponse(ID, KMID.s, Success, DataString.s)
	Declare.s _StoragePath(ID, RelPath.s)
	Declare _Remove(ID)
	Declare _CloseByID(ID)
	Declare _Dispatch(ID, RawMsg.s)
	Declare _DoList(ID, KMID.s, Path.s)
	Declare _DoStat(ID, KMID.s, Path.s)
	Declare _DoStatForRead(ID, KMID.s, Path.s)
	Declare _DoRead(ID, KMID.s, FileID)
	Declare _DoWrite(ID, KMID.s, Path.s, Content.s)
	Declare _DoMkdir(ID, KMID.s, Path.s)
	Declare _DoDelete(ID, KMID.s, Path.s)
	Declare _ListCallback(Success, DataString.s)
	Declare _StatCallback(Success, DataString.s)
	Declare _StatForReadCallback(Success, DataString.s)
	Declare _ReadCallback(Success, DataString.s)
	Declare _WriteCallback(Success, DataString.s)
	Declare _ConfirmCallback(Result)
	Declare Handler_Resize()
	Declare Handler_Close()
	
	;- Public procedures
	
	; Call once from Desktop::Open.
	Procedure Init()
		!window._kumos_rt = { instances: {} };
		!window.addEventListener('message', function(e) {
		!  var inst;
		!  for (var id in window._kumos_rt.instances) {
		!    inst = window._kumos_rt.instances[id];
		!    if (inst && inst.frameEl && inst.frameEl.contentWindow === e.source) {
		!      if (typeof e.data === 'string') {
		!        appruntime$f__dispatch(inst.id, e.data);
		!      }
		!      return;
		!    }
		!  }
		!});
	EndProcedure
	
	; Open a new sandboxed app window.
	Procedure Launch(AppID.s, ManifestJSON.s, Permissions.s)
		Protected Count, W, H, Win, View, ID
		Protected AppName.s, N.s, Src.s
		
		AppName = AppID
		If ParseJSON(0, ManifestJSON)
			N = GetJSONString(GetJSONMember(JSONValue(0), "name"))
			If N <> "" : AppName = N : EndIf
			FreeJSON(0)
		EndIf
		
		Count = ListSize(Instances())
		W     = 640
		H     = 480
		Win   = OpenWindow(#PB_Any, 80 + (Count % 8) * 24, 80 + (Count % 8) * 24, W, H, AppName, #PB_Window_TitleBar | #PB_Window_SizeGadget | #PB_Window_SystemMenu)
		View  = WebGadget(#PB_Any, 0, 0, W, H, "about:blank")
		Src   = "/api/apps/" + AppID + "/index.html"
		ID    = _NextID
		_NextID + 1
		
		!(function(){
		!  var frames = document.querySelectorAll('iframe[src="about:blank"]:not([data-kumos-instance])');
		!  var f = frames[frames.length - 1];
		!  if (!f) return;
		!  f.setAttribute('data-kumos-instance', String(v_id));
		!  // No allow-same-origin -> cross-origin isolation even from same host.
		!  f.setAttribute('sandbox', 'allow-scripts allow-forms allow-modals allow-downloads allow-pointer-lock');
		!  window._kumos_rt.instances[v_id] = { id: v_id, frameEl: f, appId: v_appid };
		!  f.src = v_src;
		!})();
		
		AddElement(Instances())
		Instances()\InstanceID = ID
		Instances()\Window     = Win
		Instances()\View       = View
		Instances()\AppID      = AppID
		Instances()\Perms      = Permissions
		
		BindEvent(#PB_Event_SizeWindow,  @Handler_Resize(), Win)
		BindEvent(#PB_Event_CloseWindow, @Handler_Close(),  Win)
		
		Desktop::Register(AppName, Win, "📦")
	EndProcedure
	
	;- Private procedures
	; Leaves list cursor on the found element. Returns #True if found.
	Procedure _FindByID(ID)
		ForEach Instances()
			If Instances()\InstanceID = ID : ProcedureReturn #True : EndIf
		Next
		ProcedureReturn #False
	EndProcedure
	
	Procedure _FindByWindow(Win)
		ForEach Instances()
			If Instances()\Window = Win : ProcedureReturn #True : EndIf
		Next
		ProcedureReturn #False
	EndProcedure
	
	Procedure _HasPerm(ID, Token.s)
		If Not _FindByID(ID) : ProcedureReturn #False : EndIf
		ProcedureReturn Bool(FindString(Instances()\Perms, ~"\"" + Token + ~"\"") > 0)
	EndProcedure
	
	Procedure _SendResponse(ID, KMID.s, Success, DataString.s)
		!var inst = window._kumos_rt.instances[v_id];
		!if (!inst || !inst.frameEl) return;
		!var resp = { kmid: v_kmid, success: !!v_success };
		!if (v_success) {
		!  try  { resp.data = JSON.parse(v_datastring); }
		!  catch(e) { resp.data = v_datastring || null; }
		!} else {
		!  resp.error = v_datastring || 'Error';
		!}
		!try { inst.frameEl.contentWindow.postMessage(JSON.stringify(resp), '*'); } catch(e) {}
	EndProcedure
	
	Procedure.s _StoragePath(ID, RelPath.s)
		Protected Base.s
		If Not _FindByID(ID) : ProcedureReturn "" : EndIf
		Base = "/.apps/" + Instances()\AppID + "/"
		If RelPath = "" Or RelPath = "/" : ProcedureReturn Base : EndIf
		If Left(RelPath, 1) = "/" : RelPath = Mid(RelPath, 2) : EndIf
		ProcedureReturn Base + RelPath
	EndProcedure
	
	Procedure _Remove(ID)
		If Not _FindByID(ID) : ProcedureReturn : EndIf
		!delete window._kumos_rt.instances[v_id];
		UnbindEvent(#PB_Event_SizeWindow,  @Handler_Resize(), Instances()\Window)
		UnbindEvent(#PB_Event_CloseWindow, @Handler_Close(),  Instances()\Window)
		Desktop::Unregister(Instances()\Window)
		DeleteElement(Instances())
	EndProcedure
	
	Procedure _CloseByID(ID)
		_Remove(ID)
	EndProcedure
	
	;- Message dispatcher
	Procedure _Dispatch(ID, RawMsg.s)
		Protected Root, Args, TType
		Protected KMID.s, Action.s, Path.s, Content.s, Title_.s, Msg.s, MsgType.s
		
		If Not _FindByID(ID) : ProcedureReturn : EndIf
		If Not ParseJSON(0, RawMsg) : ProcedureReturn : EndIf
		
		Root   = JSONValue(0)
		KMID   = GetJSONString(GetJSONMember(Root, "kmid"))
		Action = GetJSONString(GetJSONMember(Root, "action"))
		Args   = GetJSONMember(Root, "args")
		
		If KMID = "" : FreeJSON(0) : ProcedureReturn : EndIf
		
		Path    = GetJSONString(GetJSONMember(Args, "path"))
		Content = GetJSONString(GetJSONMember(Args, "content"))
		Title_  = GetJSONString(GetJSONMember(Args, "title"))
		Msg     = GetJSONString(GetJSONMember(Args, "message"))
		MsgType = GetJSONString(GetJSONMember(Args, "type"))
		FreeJSON(0)
		
		Select Action
				
			Case "window.ready"
				_FindByID(ID)
				_SendResponse(ID, KMID, #True, ~"{\"app_id\":\"" + ReplaceString(Instances()\AppID, ~"\"", ~"\\\"") + ~"\"}")
				
			Case "window.setTitle"
				_FindByID(ID)
				SetWindowTitle(Instances()\Window, Title_)
				_SendResponse(ID, KMID, #True, "")
				
			Case "window.close"
				_SendResponse(ID, KMID, #True, "")
				!setTimeout(function(){ appruntime$f__closebyid(v_id); }, 50);
				
			Case "notify.toast"
				If Not _HasPerm(ID, "notify") : _SendResponse(ID, KMID, #False, "Permission denied: notify") : ProcedureReturn : EndIf
				TType = Notify::#Info
				If MsgType = "success"     : TType = Notify::#Success
				ElseIf MsgType = "warning" : TType = Notify::#Warning
				ElseIf MsgType = "error"   : TType = Notify::#Error
				EndIf
				Notify::Toast(Msg, TType)
				_SendResponse(ID, KMID, #True, "")
				
			Case "notify.confirm"
				If Not _HasPerm(ID, "notify") : _SendResponse(ID, KMID, #False, "Permission denied: notify") : ProcedureReturn : EndIf
				If _PC_ID >= 0 : _SendResponse(ID, KMID, #False, "A dialog is already open") : ProcedureReturn : EndIf
				_PC_ID = ID : _PC_KMID = KMID
				Notify::Confirm(Title_, Msg, @_ConfirmCallback())
				
			Case "storage.list"   : _DoList(ID, KMID, _StoragePath(ID, Path))
			Case "storage.stat"   : _DoStat(ID, KMID, _StoragePath(ID, Path))
			Case "storage.read"   : _DoStatForRead(ID, KMID, _StoragePath(ID, Path))
			Case "storage.write"  : _DoWrite(ID, KMID, _StoragePath(ID, Path), Content)
			Case "storage.mkdir"  : _DoMkdir(ID, KMID, _StoragePath(ID, Path))
			Case "storage.delete" : _DoDelete(ID, KMID, _StoragePath(ID, Path))
				
			Case "fs.list"
				If Not _HasPerm(ID, "fs.read") : _SendResponse(ID, KMID, #False, "Permission denied: fs.read") : ProcedureReturn : EndIf
				_DoList(ID, KMID, Path)
				
			Case "fs.stat"
				If Not _HasPerm(ID, "fs.read") : _SendResponse(ID, KMID, #False, "Permission denied: fs.read") : ProcedureReturn : EndIf
				_DoStat(ID, KMID, Path)
				
			Case "fs.read"
				If Not _HasPerm(ID, "fs.read") : _SendResponse(ID, KMID, #False, "Permission denied: fs.read") : ProcedureReturn : EndIf
				_DoStatForRead(ID, KMID, Path)
				
			Case "fs.write"
				If Not _HasPerm(ID, "fs.write") : _SendResponse(ID, KMID, #False, "Permission denied: fs.write") : ProcedureReturn : EndIf
				_DoWrite(ID, KMID, Path, Content)
				
			Default
				_SendResponse(ID, KMID, #False, "Unknown action: " + Action)
				
		EndSelect
	EndProcedure
	
	;- Async FS helpers
	Procedure _DoList(ID, KMID.s, Path.s)
		If _PL_ID >= 0 : _SendResponse(ID, KMID, #False, "Busy") : ProcedureReturn : EndIf
		_PL_ID = ID : _PL_KMID = KMID
		HTTPRequest(#PB_HTTP_Get, "/api/fs/list?path=" + URLEncoder(Path, #PB_UTF8), "", @_ListCallback())
	EndProcedure
	
	Procedure _DoStat(ID, KMID.s, Path.s)
		If _PS_ID >= 0 : _SendResponse(ID, KMID, #False, "Busy") : ProcedureReturn : EndIf
		_PS_ID = ID : _PS_KMID = KMID
		HTTPRequest(#PB_HTTP_Get, "/api/fs/stat?path=" + URLEncoder(Path, #PB_UTF8), "", @_StatCallback())
	EndProcedure
	
	Procedure _DoStatForRead(ID, KMID.s, Path.s)
		If _PSR_ID >= 0 : _SendResponse(ID, KMID, #False, "Busy") : ProcedureReturn : EndIf
		_PSR_ID = ID : _PSR_KMID = KMID
		HTTPRequest(#PB_HTTP_Get, "/api/fs/stat?path=" + URLEncoder(Path, #PB_UTF8), "", @_StatForReadCallback())
	EndProcedure
	
	Procedure _DoRead(ID, KMID.s, FileID)
		If _PR_ID >= 0 : _SendResponse(ID, KMID, #False, "Busy") : ProcedureReturn : EndIf
		_PR_ID = ID : _PR_KMID = KMID
		HTTPRequest(#PB_HTTP_Get, "/api/fs/read?id=" + FileID, "", @_ReadCallback())
	EndProcedure
	
	Procedure _DoWrite(ID, KMID.s, Path.s, Content.s)
		If _PW_ID >= 0 : _SendResponse(ID, KMID, #False, "Busy") : ProcedureReturn : EndIf
		_PW_ID = ID : _PW_KMID = KMID
		HTTPRequest(#PB_HTTP_Post, "/api/fs/write", "path=" + URLEncoder(Path, #PB_UTF8) + "&content=" + URLEncoder(Content, #PB_UTF8), @_WriteCallback())
	EndProcedure
	
	Procedure _DoMkdir(ID, KMID.s, Path.s)
		If _PW_ID >= 0 : _SendResponse(ID, KMID, #False, "Busy") : ProcedureReturn : EndIf
		_PW_ID = ID : _PW_KMID = KMID
		HTTPRequest(#PB_HTTP_Post, "/api/fs/mkdir", "path=" + URLEncoder(Path, #PB_UTF8), @_WriteCallback())
	EndProcedure
	
	Procedure _DoDelete(ID, KMID.s, Path.s)
		If _PSR_ID >= 0 : _SendResponse(ID, KMID, #False, "Busy") : ProcedureReturn : EndIf
		_PSR_ID = ID : _PSR_KMID = "DEL:" + KMID
		HTTPRequest(#PB_HTTP_Get, "/api/fs/stat?path=" + URLEncoder(Path, #PB_UTF8), "", @_StatForReadCallback())
	EndProcedure
	
	;- Callbacks
	Procedure _ListCallback(Success, DataString.s)
		Protected ID
		Protected KMID.s
		If _PL_ID < 0 : ProcedureReturn : EndIf
		ID = _PL_ID : KMID = _PL_KMID
		_PL_ID = -1 : _PL_KMID = ""
		_SendResponse(ID, KMID, Success, DataString)
	EndProcedure
	
	Procedure _StatCallback(Success, DataString.s)
		Protected ID
		Protected KMID.s
		If _PS_ID < 0 : ProcedureReturn : EndIf
		ID = _PS_ID : KMID = _PS_KMID
		_PS_ID = -1 : _PS_KMID = ""
		_SendResponse(ID, KMID, Success, DataString)
	EndProcedure
	
	Procedure _StatForReadCallback(Success, DataString.s)
		Protected ID, IsDelete, IsDir, FileID, Root
		Protected Tag.s, KMID.s
		
		If _PSR_ID < 0 : ProcedureReturn : EndIf
		ID  = _PSR_ID
		Tag = _PSR_KMID
		_PSR_ID = -1 : _PSR_KMID = ""
		
		IsDelete = Bool(Left(Tag, 4) = "DEL:")
		If IsDelete : KMID = Mid(Tag, 5) : Else : KMID = Tag : EndIf
		
		If Not Success : _SendResponse(ID, KMID, #False, "Not found") : ProcedureReturn : EndIf
		If Not ParseJSON(0, DataString) : _SendResponse(ID, KMID, #False, "Bad stat response") : ProcedureReturn : EndIf
		
		Root   = JSONValue(0)
		IsDir  = GetJSONInteger(GetJSONMember(Root, "is_dir"))
		FileID = GetJSONInteger(GetJSONMember(Root, "id"))
		FreeJSON(0)
		
		If FileID <= 0 : _SendResponse(ID, KMID, #False, "Not found") : ProcedureReturn : EndIf
		
		If IsDelete
			If _PW_ID >= 0 : _SendResponse(ID, KMID, #False, "Busy") : ProcedureReturn : EndIf
			_PW_ID = ID : _PW_KMID = KMID
			HTTPRequest(#PB_HTTP_Post, "/api/fs/delete", "id=" + FileID, @_WriteCallback())
		Else
			If IsDir : _SendResponse(ID, KMID, #False, "Is a directory") : ProcedureReturn : EndIf
			_DoRead(ID, KMID, FileID)
		EndIf
	EndProcedure
	
	Procedure _ReadCallback(Success, DataString.s)
		Protected ID
		Protected KMID.s
		If _PR_ID < 0 : ProcedureReturn : EndIf
		ID = _PR_ID : KMID = _PR_KMID
		_PR_ID = -1 : _PR_KMID = ""
		_SendResponse(ID, KMID, Success, DataString)
	EndProcedure
	
	Procedure _WriteCallback(Success, DataString.s)
		Protected ID, OK
		Protected KMID.s
		
		If _PW_ID < 0 : ProcedureReturn : EndIf
		ID = _PW_ID : KMID = _PW_KMID
		_PW_ID = -1 : _PW_KMID = ""
		
		If Not Success : _SendResponse(ID, KMID, #False, "Request failed") : ProcedureReturn : EndIf
		
		If ParseJSON(0, DataString)
			OK = GetJSONBoolean(GetJSONMember(JSONValue(0), "success"))
			FreeJSON(0)
			_SendResponse(ID, KMID, OK, "")
		Else
			_SendResponse(ID, KMID, #False, "Bad server response")
		EndIf
	EndProcedure
	
	Procedure _ConfirmCallback(Result)
		Protected ID
		Protected KMID.s, BoolStr.s
		
		If _PC_ID < 0 : ProcedureReturn : EndIf
		ID = _PC_ID : KMID = _PC_KMID
		_PC_ID = -1 : _PC_KMID = ""
		
		BoolStr = "false"
		If Result : BoolStr = "true" : EndIf
		_SendResponse(ID, KMID, #True, BoolStr)
	EndProcedure
	
	;- Window event handlers
	Procedure Handler_Resize()
		If Not _FindByWindow(EventWindow()) : ProcedureReturn : EndIf
		ResizeGadget(Instances()\View, 0, 0, WindowWidth(Instances()\Window), WindowHeight(Instances()\Window))
	EndProcedure
	
	Procedure Handler_Close()
		If Not _FindByWindow(EventWindow()) : ProcedureReturn : EndIf
		_Remove(Instances()\InstanceID)
	EndProcedure
	
EndModule

; IDE Options = SpiderBasic 3.20 (Windows - x86)
; CursorPosition = 2
; Folding = BAAA9
; iOSAppOrientation = 0
; AndroidAppCode = 0
; AndroidAppOrientation = 0
; EnableXP
; DPIAware
; CompileSourceDirectory